diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 443ca7b..d2ccc54 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); + }); + } + @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.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. @@ -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..f137f00 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,35 @@ public boolean sendRadianceCast(String groupKey, int tier, long remainingMs) { return true; } + /** + * 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) { + 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()) { + 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); + } + return obj; + } + /** * 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 +1169,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..b75fd2d --- /dev/null +++ b/src/tel/eden/mod/raid/WitnessCooldownTracker.java @@ -0,0 +1,221 @@ +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(); + } + + /** + * 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; + } + 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(); + } + + /** + * 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; + } + long now = timeSupplier.getAsLong(); + if (now - lastTriggerMs < DEBOUNCE_WINDOW_MS) { + return; + } + lastTriggerMs = now; + cooldownExpiresAtMs = now + COOLDOWN_DURATION_MS; + } + + 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; + } + // 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() { + 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/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 new file mode 100644 index 0000000..91abb30 --- /dev/null +++ b/test/tel/eden/mod/raid/WitnessCooldownTrackerTest.java @@ -0,0 +1,159 @@ +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 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 testChatLineWithColonIsPlayerChat() { + WitnessCooldownTracker.onChatMessage(Component.literal("[Eden] Bob: [+2 Light Crystal]")); + WitnessCooldownTracker.onChatMessage(Component.literal("Alice: lol [+2 Light Crystal]")); + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + + WitnessCooldownTracker.onChatMessage(Component.literal("§a[+2 Light Crystal]")); + assertEquals(105_000L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + } + + @Test + void testReset() { + WitnessCooldownTracker.onCrystalAcquired(); + assertTrue(WitnessCooldownTracker.getCooldownExpiresAtMs() > 0L); + + WitnessCooldownTracker.reset(); + assertEquals(0L, WitnessCooldownTracker.getCooldownExpiresAtMs()); + assertEquals(0L, WitnessCooldownTracker.getLastTriggerMs()); + } +}