From cc9051630e015edbc8c44c72b4683c963ab26ba1 Mon Sep 17 00:00:00 2001 From: Good627 <193092356+Good627@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:08:52 +0300 Subject: [PATCH 1/2] feat: FINALV1WITNESSCOOLDOWN - Light Gathering Room 2 Athanatic Witness kill cooldown tracker HUD --- src/tel/eden/mod/EdenModClient.java | 31 +++ src/tel/eden/mod/config/BridgeConfig.java | 3 + src/tel/eden/mod/gui/BridgeConfigScreen.java | 1 + src/tel/eden/mod/hud/HudPanels.java | 2 +- .../mod/mixin/ClientPacketListenerMixin.java | 6 +- .../eden/mod/net/BridgeWebSocketClient.java | 33 +++ .../eden/mod/party/PartyHealthTracker.java | 10 + .../eden/mod/raid/WitnessCooldownTracker.java | 208 ++++++++++++++++++ .../mod/raid/WitnessCooldownTrackerTest.java | 150 +++++++++++++ 9 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 src/tel/eden/mod/raid/WitnessCooldownTracker.java create mode 100644 test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 443ca7b..aad512b 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -232,6 +232,10 @@ public String liveDiscordRank() { } private boolean onWynncraft; + + public boolean onWynncraft() { + return onWynncraft; + } // Mutated on the inbound-message path and read from GUI screens on the render // thread. Each change publishes one immutable snapshot through this volatile field, // so readers cannot observe the remove/add or clear/addAll halves of an update. @@ -518,6 +522,7 @@ public boolean isChiefOwnerOrStrategist() { public void onWorldRespawn() { enteredWynnGameWorld = true; guildLogSync.onWorldJoin(); + tel.eden.mod.raid.WitnessCooldownTracker.reset(); } /** Packet hooks for the invisible guild-log query (mirrors Wynntils' query path). */ @@ -639,6 +644,7 @@ public void release() { HudRenderCallback.EVENT.register((graphics, tickCounter) -> { if (onWynncraft) { WarHud.render(config, graphics); + tel.eden.mod.raid.WitnessCooldownTracker.render(config, graphics); } }); // War-suite territory outlines, added to the per-frame gizmo collector during @@ -663,6 +669,7 @@ public void release() { AllianceMenuScraper.reset(); GuildMenuScraper.reset(); AttackTimerMenu.onWorldJoin(); + tel.eden.mod.raid.WitnessCooldownTracker.reset(); tel.eden.mod.war.radiance.RadianceTracker.onJoin(); guildAllyManager.requestCancel(); // Connect if applicable; the backend reports link/membership standing via @@ -1085,6 +1092,27 @@ public void onRadianceCast(String sender, String senderUuid, String groupKey, in }); } + @Override + public void onWitnessKill(String sender, String senderUuid, long timestamp) { + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (mc.player == null) { + return; + } + String myUuid = mc.player.getUUID().toString().replace("-", ""); + String cleanSenderUuid = senderUuid != null ? senderUuid.replace("-", "").trim() : ""; + if (!cleanSenderUuid.isEmpty() && cleanSenderUuid.equalsIgnoreCase(myUuid)) { + return; + } + if (sender != null && !sender.isBlank()) { + if (sender.equalsIgnoreCase(mc.player.getGameProfile().name()) || sender.equalsIgnoreCase(mc.player.getName().getString())) { + return; + } + } + tel.eden.mod.raid.WitnessCooldownTracker.onRemoteWitnessKill(sender, timestamp); + }); + } + @Override public void onConnectionRejected(String code) { pendingConnectionCode = code; @@ -2371,6 +2399,7 @@ private synchronized void disconnect() { RaidReadyTracker.reset(); PartyHealthTracker.reset(); ChatReplyManager.reset(); + tel.eden.mod.raid.WitnessCooldownTracker.reset(); tel.eden.mod.war.radiance.RadianceTracker.onDisconnect(); guildAllyManager.requestCancel(); territoryDefendedTracker.clear(); @@ -2425,6 +2454,7 @@ public void handleSystemChat(Component message) { DiscordChatFormatter.onServerChatLine(); if (onWynncraft) { RaidReadyTracker.onSystemChat(message); + tel.eden.mod.raid.WitnessCooldownTracker.onMessage(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. @@ -2452,6 +2482,7 @@ public void handleSystemChat(Component message) { // needs a new bounded startup-recovery window for it. guildLogSync.onWorldJoin(); territoryDefendedTracker.clear(); + tel.eden.mod.raid.WitnessCooldownTracker.reset(); setInGameWorld(true); } BridgeWebSocketClient current = socket; diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 18e74bb..da91928 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -280,6 +280,9 @@ public void sanitize() { /** Disallow readying up in the raid menu if the raid is not a guild raid. */ public boolean requireGuildRaid = false; + /** Witness cooldown HUD timer for Orphion's Nexus of Light Room 2. */ + public boolean witnessCooldownTracker = 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 dd8c606..4c53fd5 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -149,6 +149,7 @@ protected void init() { IntConfigSlider gambitSlider = new IntConfigSlider(CONTROL_W, 20, 0, 3, 1, () -> config.raidGambitMinimum, v -> config.raidGambitMinimum = v, v -> Component.literal(v == 0 ? "Off (0)" : String.valueOf(v))); addSliderRow(Category.PARTY_RAIDS, "Minimum raid gambits", gambitSlider, gambitSlider::syncFromConfig, () -> config.raidGambitMinimum = 1); addToggleRow(Category.PARTY_RAIDS, "Require guild raid to ready", () -> config.requireGuildRaid, v -> config.requireGuildRaid = v, "On", "Off", false); + addToggleRow(Category.PARTY_RAIDS, "Witness cooldown timer", () -> config.witnessCooldownTracker, v -> config.witnessCooldownTracker = v, "On", "Off", true); // War & Territory addToggleRow(Category.WAR, "Attack timers HUD", () -> config.warAttackTimers, v -> config.warAttackTimers = v, "On", "Off", true); diff --git a/src/tel/eden/mod/hud/HudPanels.java b/src/tel/eden/mod/hud/HudPanels.java index 2825ec3..0baa23b 100644 --- a/src/tel/eden/mod/hud/HudPanels.java +++ b/src/tel/eden/mod/hud/HudPanels.java @@ -10,5 +10,5 @@ */ public record HudPanels(String name, String label, float defaultX, float defaultY, int previewWidth, int previewHeight) { - public static final List ALL = List.of(new HudPanels("attackTimers", "Attack timers", 0.40f, 0.14f, 130, 44), new HudPanels("warInfo", "War info overlay", 0.015f, 0.30f, 150, 74), new HudPanels("weeklyWars", "Weekly war count", 0.83f, 0.04f, 60, 12), new HudPanels("radianceTracker", "Radiance tracker", 0.50f, 0.35f, 110, 24)); + public static final List ALL = List.of(new HudPanels("attackTimers", "Attack timers", 0.40f, 0.14f, 130, 44), new HudPanels("warInfo", "War info overlay", 0.015f, 0.30f, 150, 74), new HudPanels("weeklyWars", "Weekly war count", 0.83f, 0.04f, 60, 12), new HudPanels("radianceTracker", "Radiance tracker", 0.50f, 0.35f, 110, 24), new HudPanels("witnessCooldown", "Witness cooldown", 0.45f, 0.40f, 90, 16)); } diff --git a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java index 632f5aa..86c0b37 100644 --- a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java +++ b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java @@ -29,6 +29,9 @@ public class ClientPacketListenerMixin { EdenModClient mod = EdenModClient.instance(); if (mod != null) { mod.handleWynncraftActionBar(packet.text()); + if (mod.onWynncraft()) { + tel.eden.mod.raid.WitnessCooldownTracker.onMessage(packet.text()); + } } } } @@ -122,8 +125,9 @@ public class ClientPacketListenerMixin { if (packet.overlay()) { // Reward feedback may be rendered as Wynncraft's ticker/action bar rather than a // chat line. It is bookkeeping input, but must not enter the general chat relay. - if (mod != null) { + if (mod != null && mod.onWynncraft()) { mod.handleRewardFeedback(packet.content()); + tel.eden.mod.raid.WitnessCooldownTracker.onMessage(packet.content()); } return; } diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index cd932d6..6afcf72 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -158,6 +158,10 @@ default void onRadianceCast(String sender, String groupKey, int tier, long remai default void onRadianceGroupCount(String groupKey, int count) { } + /** A raid party member or group peer reported an Athanatic Witness kill. */ + default void onWitnessKill(String sender, String senderUuid, long timestamp) { + } + /** * The Mojang session was verified ({@code authOk}); {@code linked} and * {@code member} report this player's backend standing. Both true means full @@ -658,6 +662,34 @@ public boolean sendRadianceCast(String groupKey, int tier, long remainingMs) { return true; } + /** + * Broadcast an Athanatic Witness kill to raid/party members or group key peers. + * + * @return whether the message was queued on a connected socket + */ + public boolean sendWitnessKill(java.util.List partyMembers, String groupKey) { + WebSocket current = socket; + if (current == null) { + return false; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "witnessKill"); + if (partyMembers != null && !partyMembers.isEmpty()) { + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + for (String member : partyMembers) { + if (member != null && !member.isBlank()) { + arr.add(member.trim()); + } + } + obj.add("party", arr); + } + if (groupKey != null && !groupKey.isBlank()) { + obj.addProperty("groupKey", groupKey.trim()); + } + enqueueSend(current, obj.toString()); + return true; + } + /** * Toggle this player's "heading to {@code territory}" marker (right-click on a timer * row). One assignment per player: marking a new territory moves them, re-marking the @@ -1136,6 +1168,7 @@ private void handlePayload(String payload) { case "warBoard" -> sink.onWarBoard(parseWarBoard(obj)); case "radianceGroupCount" -> sink.onRadianceGroupCount(get(obj, "groupKey"), getInt(obj, "count", 0)); case "radianceCast" -> sink.onRadianceCast(get(obj, "sender"), get(obj, "senderUuid"), get(obj, "groupKey"), getInt(obj, "tier", 0), getLong(obj, "remainingMs", 0L), getLong(obj, "timestamp", System.currentTimeMillis())); + case "witnessKill" -> sink.onWitnessKill(get(obj, "sender"), get(obj, "senderUuid"), getLong(obj, "timestamp", System.currentTimeMillis())); case "authChallenge" -> handleAuthChallenge(get(obj, "serverId")); case "authOk" -> { // Session verified: reset backoff and hand the backend-reported diff --git a/src/tel/eden/mod/party/PartyHealthTracker.java b/src/tel/eden/mod/party/PartyHealthTracker.java index 6937755..399ad87 100644 --- a/src/tel/eden/mod/party/PartyHealthTracker.java +++ b/src/tel/eden/mod/party/PartyHealthTracker.java @@ -93,6 +93,16 @@ public static PlayerHealthData getHealth(UUID uuid) { return HEALTH_BY_UUID.get(uuid); } + public static List getTrackedPartyNames() { + List names = new ArrayList<>(); + for (PlayerHealthData data : HEALTH_BY_UUID.values()) { + if (data.name() != null && !data.name().isBlank()) { + names.add(data.name()); + } + } + return names; + } + public static void tick() { tickCounter++; if (tickCounter % 5 != 0) { diff --git a/src/tel/eden/mod/raid/WitnessCooldownTracker.java b/src/tel/eden/mod/raid/WitnessCooldownTracker.java new file mode 100644 index 0000000..298fa10 --- /dev/null +++ b/src/tel/eden/mod/raid/WitnessCooldownTracker.java @@ -0,0 +1,208 @@ +package tel.eden.mod.raid; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.LongSupplier; +import java.util.regex.Pattern; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.chat.ChatText; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.hud.HudPanel; +import tel.eden.mod.hud.HudRender; +import tel.eden.mod.hud.RectangleElement; +import tel.eden.mod.hud.TextElement; +import tel.eden.mod.net.BridgeWebSocketClient; +import tel.eden.mod.party.PartyHealthTracker; +import tel.eden.mod.party.WynntilsPartyBridge; +import tel.eden.mod.war.WarDPS; + +/** + * Tracks the 5.0-second cooldown on Athanatic Witness drops in Orphion's Nexus of Light Room 2. + * Synchronizes with raid party members via EdenBot so that all 4 players in the raid see the countdown. + */ +public final class WitnessCooldownTracker { + private WitnessCooldownTracker() { + } + + public static final String PANEL = "witnessCooldown"; + public static final float DEFAULT_X = 0.45f; + public static final float DEFAULT_Y = 0.40f; + public static final long COOLDOWN_DURATION_MS = 5000L; + public static final long DEBOUNCE_WINDOW_MS = 1000L; + public static final long READY_TIMEOUT_MS = 30_000L; + private static final int PANEL_BG = 0x80000000; + + private static final Pattern CRYSTAL_PATTERN = Pattern.compile("\\[\\+2\\s+Light\\s+Crystal\\]", Pattern.CASE_INSENSITIVE); + + private static volatile long cooldownExpiresAtMs = 0L; + private static volatile long lastTriggerMs = 0L; + private static LongSupplier timeSupplier = System::currentTimeMillis; + + public static void setTimeSupplier(LongSupplier supplier) { + timeSupplier = supplier != null ? supplier : System::currentTimeMillis; + } + + public static LongSupplier getTimeSupplier() { + return timeSupplier; + } + + public static long getCooldownExpiresAtMs() { + return cooldownExpiresAtMs; + } + + public static long getLastTriggerMs() { + return lastTriggerMs; + } + + public static boolean isMatchingMessage(String cleanText) { + if (cleanText == null || cleanText.isBlank()) { + return false; + } + return CRYSTAL_PATTERN.matcher(cleanText).find(); + } + + public static void onMessage(Component message) { + if (message == null) { + return; + } + BridgeConfig config = EdenModClient.instance() != null ? EdenModClient.instance().config() : null; + if (config != null && !config.witnessCooldownTracker) { + return; + } + String clean = ChatText.normalize(WarDPS.clean(message.getString())); + if (isMatchingMessage(clean)) { + onCrystalAcquired(); + } + } + + public static void onCrystalAcquired() { + BridgeConfig config = EdenModClient.instance() != null ? EdenModClient.instance().config() : null; + if (config != null && !config.witnessCooldownTracker) { + return; + } + long now = timeSupplier.getAsLong(); + if (now - lastTriggerMs < DEBOUNCE_WINDOW_MS) { + return; + } + lastTriggerMs = now; + cooldownExpiresAtMs = now + COOLDOWN_DURATION_MS; + broadcastLocalKill(); + } + + public static void onRemoteWitnessKill(String sender, long timestamp) { + BridgeConfig config = EdenModClient.instance() != null ? EdenModClient.instance().config() : null; + if (config != null && !config.witnessCooldownTracker) { + return; + } + long now = timeSupplier.getAsLong(); + if (now - lastTriggerMs < DEBOUNCE_WINDOW_MS) { + return; + } + long elapsed = Math.max(0, now - timestamp); + long remaining = Math.max(0, COOLDOWN_DURATION_MS - elapsed); + if (remaining > 0) { + lastTriggerMs = now; + cooldownExpiresAtMs = now + remaining; + } + } + + private static void broadcastLocalKill() { + EdenModClient mod = EdenModClient.instance(); + if (mod == null) { + return; + } + BridgeConfig config = mod.config(); + if (config != null && !config.witnessCooldownTracker) { + return; + } + BridgeWebSocketClient socket = mod.socket(); + if (socket == null) { + return; + } + List partyMembers = getRaidPartyMembers(); + String groupKey = config != null ? config.radianceGroupKey : null; + socket.sendWitnessKill(partyMembers, groupKey); + } + + public static List getRaidPartyMembers() { + Set members = new LinkedHashSet<>(); + List wynntilsMembers = WynntilsPartyBridge.getPartyMembers(); + if (wynntilsMembers != null) { + members.addAll(wynntilsMembers); + } + List tracked = PartyHealthTracker.getTrackedPartyNames(); + if (tracked != null) { + members.addAll(tracked); + } + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null && mc.player.getGameProfile() != null) { + String name = mc.player.getGameProfile().name(); + if (name != null && !name.isBlank()) { + members.add(name); + } + } + return new ArrayList<>(members); + } + + public static double getRemainingSeconds(long now) { + return Math.max(0.0, (cooldownExpiresAtMs - now) / 1000.0); + } + + public static String getStatusText(long now) { + if (now < cooldownExpiresAtMs) { + return String.format(Locale.ROOT, "Witness: %.1fs", getRemainingSeconds(now)); + } + return "Witness: READY"; + } + + public static int getStatusColor(long now) { + if (now >= cooldownExpiresAtMs) { + return 0xFF55FF55; // Green + } + double remainingSec = getRemainingSeconds(now); + float progress = (float) Math.min(1.0, Math.max(0.0, 1.0 - (remainingSec / 5.0))); + int green = 85 + (int) (progress * 170.0f); + return 0xFF000000 | (255 << 16) | (green << 8) | 85; + } + + public static void render(BridgeConfig config, GuiGraphics graphics) { + if (config == null || !config.witnessCooldownTracker) { + return; + } + if (cooldownExpiresAtMs == 0L) { + return; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null || mc.level == null || mc.options.hideGui) { + return; + } + long now = timeSupplier.getAsLong(); + if (now >= cooldownExpiresAtMs && (now - cooldownExpiresAtMs) > READY_TIMEOUT_MS) { + return; + } + + String text = getStatusText(now); + int color = getStatusColor(now); + int textW = mc.font.width(text); + int panelW = Math.max(90, textW + 10); + int panelH = 14; + + HudPanel panel = new HudPanel(); + panel.add(new RectangleElement(0, 0, panelW, panelH, PANEL_BG)); + int x = (panelW - textW) / 2; + panel.add(new TextElement(text, x, 3, color)); + + HudRender.draw(config, PANEL, DEFAULT_X, DEFAULT_Y, panelW, panelH, panel, graphics); + } + + public static void reset() { + cooldownExpiresAtMs = 0L; + lastTriggerMs = 0L; + } +} diff --git a/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java b/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java new file mode 100644 index 0000000..d5c0e1c --- /dev/null +++ b/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java @@ -0,0 +1,150 @@ +package tel.eden.mod.raid; + +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 java.util.concurrent.atomic.AtomicLong; +import net.minecraft.network.chat.Component; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class WitnessCooldownTrackerTest { + + private final AtomicLong currentTime = new AtomicLong(100_000L); + + @BeforeEach + void setUp() { + currentTime.set(100_000L); + WitnessCooldownTracker.setTimeSupplier(currentTime::get); + WitnessCooldownTracker.reset(); + } + + @AfterEach + void tearDown() { + WitnessCooldownTracker.reset(); + WitnessCooldownTracker.setTimeSupplier(null); + } + + @Test + void testMessageMatching() { + assertTrue(WitnessCooldownTracker.isMatchingMessage("[+2 Light Crystal]")); + assertTrue(WitnessCooldownTracker.isMatchingMessage("prefix [+2 Light Crystal] suffix")); + assertTrue(WitnessCooldownTracker.isMatchingMessage("[+2 Light Crystal]")); + assertTrue(WitnessCooldownTracker.isMatchingMessage("[+2 light crystal]")); + + assertFalse(WitnessCooldownTracker.isMatchingMessage("[+1 Light Crystal]")); + assertFalse(WitnessCooldownTracker.isMatchingMessage("[+2 Dark Crystal]")); + assertFalse(WitnessCooldownTracker.isMatchingMessage("Light Crystal")); + assertFalse(WitnessCooldownTracker.isMatchingMessage("")); + assertFalse(WitnessCooldownTracker.isMatchingMessage(null)); + } + + @Test + void testComponentParsingWithFormattingCodes() { + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + + WitnessCooldownTracker.onMessage(Component.literal("§a[+2 Light Crystal]§r")); + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + assertEquals(100_000L, WitnessCooldownTracker.getLastTriggerMs()); + } + + @Test + void testCountdownTimingAndFormatting() { + WitnessCooldownTracker.onCrystalAcquired(); + long now = currentTime.get(); + + assertEquals(5.0, WitnessCooldownTracker.getRemainingSeconds(now), 0.001); + assertEquals("Witness: 5.0s", WitnessCooldownTracker.getStatusText(now)); + + currentTime.addAndGet(800L); // 4.2s remaining + now = currentTime.get(); + assertEquals(4.2, WitnessCooldownTracker.getRemainingSeconds(now), 0.001); + assertEquals("Witness: 4.2s", WitnessCooldownTracker.getStatusText(now)); + + currentTime.addAndGet(3700L); // 0.5s remaining + now = currentTime.get(); + assertEquals(0.5, WitnessCooldownTracker.getRemainingSeconds(now), 0.001); + assertEquals("Witness: 0.5s", WitnessCooldownTracker.getStatusText(now)); + + currentTime.addAndGet(500L); // exactly expired + now = currentTime.get(); + assertEquals(0.0, WitnessCooldownTracker.getRemainingSeconds(now), 0.001); + assertEquals("Witness: READY", WitnessCooldownTracker.getStatusText(now)); + + currentTime.addAndGet(1000L); // past expiry + now = currentTime.get(); + assertEquals(0.0, WitnessCooldownTracker.getRemainingSeconds(now), 0.001); + assertEquals("Witness: READY", WitnessCooldownTracker.getStatusText(now)); + } + + @Test + void testColorInterpolation() { + WitnessCooldownTracker.onCrystalAcquired(); + long start = currentTime.get(); + + // At start (5.0s remaining -> progress 0.0 -> Red: RGB(255, 85, 85)) + int redColor = WitnessCooldownTracker.getStatusColor(start); + assertEquals(0xFFFF5555, redColor); + + // At mid (2.5s remaining -> progress 0.5 -> Orange: RGB(255, 170, 85)) + int midColor = WitnessCooldownTracker.getStatusColor(start + 2500L); + assertEquals(0xFFFFAA55, midColor); + + // Near end (0.0s remaining but before expired -> progress 1.0 -> Yellow: RGB(255, 255, 85)) + int endYellow = WitnessCooldownTracker.getStatusColor(start + 4999L); + // progress ~ 1.0 -> green = 85 + 169 = 254 + int greenChannel = (endYellow >> 8) & 0xFF; + assertTrue(greenChannel >= 250, "Green channel near end should be near 255 (yellow)"); + + // Expired -> Green: RGB(85, 255, 85) + int readyColor = WitnessCooldownTracker.getStatusColor(start + 5000L); + assertEquals(0xFF55FF55, readyColor); + } + + @Test + void testDebounceSuppressesRapidDuplicates() { + WitnessCooldownTracker.onCrystalAcquired(); + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + + // Duplicate packet arrives 150ms later (within 1000ms debounce window) + currentTime.addAndGet(150L); + WitnessCooldownTracker.onMessage(Component.literal("[+2 Light Crystal]")); + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs(), "Expiration timestamp must not reset on duplicate packet"); + + // After debounce window (1500ms total), new drop triggers freshly + currentTime.addAndGet(1350L); + WitnessCooldownTracker.onMessage(Component.literal("[+2 Light Crystal]")); + assertEquals(currentTime.get() + 5000L, WitnessCooldownTracker.getCooldownExpiresAtMs(), "Fresh trigger allowed after debounce window"); + } + + @Test + void testRemoteWitnessKill() { + long packetTimestamp = currentTime.get() - 1000L; // 1.0s ago on remote sender + WitnessCooldownTracker.onRemoteWitnessKill("Ally", packetTimestamp); + + // Elapsed is 1000ms -> remaining is 4000ms + assertEquals(currentTime.get() + 4000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + assertEquals(4.0, WitnessCooldownTracker.getRemainingSeconds(currentTime.get()), 0.001); + assertEquals("Witness: 4.0s", WitnessCooldownTracker.getStatusText(currentTime.get())); + } + + @Test + void testRemoteWitnessKillExpiredIgnored() { + long packetTimestamp = currentTime.get() - 6000L; // 6.0s ago (stale) + WitnessCooldownTracker.onRemoteWitnessKill("Ally", packetTimestamp); + + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs(), "Stale remote kill should not activate cooldown"); + } + + @Test + void testReset() { + WitnessCooldownTracker.onCrystalAcquired(); + assertTrue(WitnessCooldownTracker.getCooldownExpiresAtMs() > 0L); + + WitnessCooldownTracker.reset(); + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + assertEquals(0L, WitnessCooldownTracker.getLastTriggerMs()); + } +} From f400337a57d6a576d842928de88453d41e34b9fa Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:54:57 +0400 Subject: [PATCH 2/2] fix(witness): sync kills to the raid party only, time remote kills on arrival, ignore player chat The Radiance group key is a war grouping, so sending it relayed raid kills to war teammates outside the raid. Remote kills compared the bot's timestamp with the local clock, which dropped every party kill for a player whose clock ran a few seconds fast. Chat lines containing a colon are player chat, never the pickup notice. --- src/tel/eden/mod/EdenModClient.java | 4 +-- .../eden/mod/net/BridgeWebSocketClient.java | 15 +++++---- .../eden/mod/raid/WitnessCooldownTracker.java | 33 +++++++++++++------ .../net/BridgeWebSocketClientWitnessTest.java | 21 ++++++++++++ .../mod/raid/WitnessCooldownTrackerTest.java | 33 ++++++++++++------- 5 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 test/tel/eden/mod/net/BridgeWebSocketClientWitnessTest.java diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index aad512b..d2ccc54 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -1109,7 +1109,7 @@ public void onWitnessKill(String sender, String senderUuid, long timestamp) { return; } } - tel.eden.mod.raid.WitnessCooldownTracker.onRemoteWitnessKill(sender, timestamp); + tel.eden.mod.raid.WitnessCooldownTracker.onRemoteWitnessKill(sender); }); } @@ -2454,7 +2454,7 @@ public void handleSystemChat(Component message) { DiscordChatFormatter.onServerChatLine(); if (onWynncraft) { RaidReadyTracker.onSystemChat(message); - tel.eden.mod.raid.WitnessCooldownTracker.onMessage(message); + tel.eden.mod.raid.WitnessCooldownTracker.onChatMessage(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. diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 6afcf72..f137f00 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -663,15 +663,20 @@ public boolean sendRadianceCast(String groupKey, int tier, long remainingMs) { } /** - * Broadcast an Athanatic Witness kill to raid/party members or group key peers. + * Broadcast an Athanatic Witness kill to the raid party. * * @return whether the message was queued on a connected socket */ - public boolean sendWitnessKill(java.util.List partyMembers, String groupKey) { + public boolean sendWitnessKill(java.util.List partyMembers) { WebSocket current = socket; if (current == null) { return false; } + enqueueSend(current, witnessKillPayload(partyMembers).toString()); + return true; + } + + static JsonObject witnessKillPayload(java.util.List partyMembers) { JsonObject obj = new JsonObject(); obj.addProperty("type", "witnessKill"); if (partyMembers != null && !partyMembers.isEmpty()) { @@ -683,11 +688,7 @@ public boolean sendWitnessKill(java.util.List partyMembers, String group } obj.add("party", arr); } - if (groupKey != null && !groupKey.isBlank()) { - obj.addProperty("groupKey", groupKey.trim()); - } - enqueueSend(current, obj.toString()); - return true; + return obj; } /** diff --git a/src/tel/eden/mod/raid/WitnessCooldownTracker.java b/src/tel/eden/mod/raid/WitnessCooldownTracker.java index 298fa10..b75fd2d 100644 --- a/src/tel/eden/mod/raid/WitnessCooldownTracker.java +++ b/src/tel/eden/mod/raid/WitnessCooldownTracker.java @@ -67,6 +67,18 @@ public static boolean isMatchingMessage(String cleanText) { return CRYSTAL_PATTERN.matcher(cleanText).find(); } + /** + * Server chat line. Player chat (guild, party, global) always reads {@code name: message}, + * so any line with a colon is someone typing the phrase, never the real pickup notice. + */ + public static void onChatMessage(Component message) { + if (message == null || message.getString().indexOf(':') >= 0) { + return; + } + onMessage(message); + } + + /** Action bar or overlay text, which players cannot write into. */ public static void onMessage(Component message) { if (message == null) { return; @@ -95,7 +107,12 @@ public static void onCrystalAcquired() { broadcastLocalKill(); } - public static void onRemoteWitnessKill(String sender, long timestamp) { + /** + * A party member's kill, relayed by EdenBot. Timed from arrival rather than the bot's + * timestamp: the relay is near-instant, while comparing the bot's clock to ours would + * drop every remote kill for a player whose clock runs a few seconds fast. + */ + public static void onRemoteWitnessKill(String sender) { BridgeConfig config = EdenModClient.instance() != null ? EdenModClient.instance().config() : null; if (config != null && !config.witnessCooldownTracker) { return; @@ -104,12 +121,8 @@ public static void onRemoteWitnessKill(String sender, long timestamp) { if (now - lastTriggerMs < DEBOUNCE_WINDOW_MS) { return; } - long elapsed = Math.max(0, now - timestamp); - long remaining = Math.max(0, COOLDOWN_DURATION_MS - elapsed); - if (remaining > 0) { - lastTriggerMs = now; - cooldownExpiresAtMs = now + remaining; - } + lastTriggerMs = now; + cooldownExpiresAtMs = now + COOLDOWN_DURATION_MS; } private static void broadcastLocalKill() { @@ -125,9 +138,9 @@ private static void broadcastLocalKill() { if (socket == null) { return; } - List partyMembers = getRaidPartyMembers(); - String groupKey = config != null ? config.radianceGroupKey : null; - socket.sendWitnessKill(partyMembers, groupKey); + // Party only: the Radiance group key is a war grouping, and matching on it would put + // this raid's timer on the screens of war teammates who aren't in the raid. + socket.sendWitnessKill(getRaidPartyMembers()); } public static List getRaidPartyMembers() { diff --git a/test/tel/eden/mod/net/BridgeWebSocketClientWitnessTest.java b/test/tel/eden/mod/net/BridgeWebSocketClientWitnessTest.java new file mode 100644 index 0000000..6b6dadc --- /dev/null +++ b/test/tel/eden/mod/net/BridgeWebSocketClientWitnessTest.java @@ -0,0 +1,21 @@ +package tel.eden.mod.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.google.gson.JsonObject; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BridgeWebSocketClientWitnessTest { + + @Test + void witnessKillCarriesPartyAndNoGroupKey() { + JsonObject obj = BridgeWebSocketClient.witnessKillPayload(List.of("Alice", " Bob ", "")); + + assertEquals("witnessKill", obj.get("type").getAsString()); + assertEquals(2, obj.getAsJsonArray("party").size()); + assertEquals("Bob", obj.getAsJsonArray("party").get(1).getAsString()); + assertFalse(obj.has("groupKey"), "The Radiance group key would relay raid kills to war teammates"); + } +} diff --git a/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java b/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java index d5c0e1c..91abb30 100644 --- a/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java +++ b/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java @@ -120,22 +120,31 @@ void testDebounceSuppressesRapidDuplicates() { } @Test - void testRemoteWitnessKill() { - long packetTimestamp = currentTime.get() - 1000L; // 1.0s ago on remote sender - WitnessCooldownTracker.onRemoteWitnessKill("Ally", packetTimestamp); - - // Elapsed is 1000ms -> remaining is 4000ms - assertEquals(currentTime.get() + 4000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); - assertEquals(4.0, WitnessCooldownTracker.getRemainingSeconds(currentTime.get()), 0.001); - assertEquals("Witness: 4.0s", WitnessCooldownTracker.getStatusText(currentTime.get())); + void testRemoteWitnessKillTimedFromArrival() { + WitnessCooldownTracker.onRemoteWitnessKill("Ally"); + + // The bot's timestamp is ignored, so a skewed clock on either side can't shorten or drop it + assertEquals(currentTime.get() + 5000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + assertEquals("Witness: 5.0s", WitnessCooldownTracker.getStatusText(currentTime.get())); + } + + @Test + void testRemoteWitnessKillDebouncedAfterLocalKill() { + WitnessCooldownTracker.onCrystalAcquired(); + currentTime.addAndGet(300L); + WitnessCooldownTracker.onRemoteWitnessKill("Ally"); + + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs(), "Relay of the same kill must not restart the timer"); } @Test - void testRemoteWitnessKillExpiredIgnored() { - long packetTimestamp = currentTime.get() - 6000L; // 6.0s ago (stale) - WitnessCooldownTracker.onRemoteWitnessKill("Ally", packetTimestamp); + void testChatLineWithColonIsPlayerChat() { + WitnessCooldownTracker.onChatMessage(Component.literal("[Eden] Bob: [+2 Light Crystal]")); + WitnessCooldownTracker.onChatMessage(Component.literal("Alice: lol [+2 Light Crystal]")); + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs()); - assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs(), "Stale remote kill should not activate cooldown"); + WitnessCooldownTracker.onChatMessage(Component.literal("§a[+2 Light Crystal]")); + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); } @Test