From 9595e4534005b104ccf6ae0b2b88aae44d6d2c44 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:07 +0400 Subject: [PATCH 1/4] fix(rewards): back off guild rank lookups for 60s after any attempt A failed lookup (API error or a guildless player) never stamped lastRefresh, so the per-tick menu scrapers fired one Wynncraft API request after another whenever a container was open without a known rank. ensureFresh is also reached from the websocket thread, so it is now synchronized. --- src/tel/eden/mod/reward/GuildRewards.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index f281483..6da4b9b 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -102,6 +102,10 @@ public final class GuildRewards { private static final EdenLogger LOGGER = EdenLogger.get(); private static final long WEEK_MS = 604_800_000L; private static final long REFRESH_INTERVAL_MS = 600_000L; // 10 min, like the script + // Minimum gap between refresh attempts, successful or not. A failed lookup (API error, + // or a guildless player) never stamps lastRefresh, and callers like the per-tick menu + // scrapers would otherwise fire one Wynncraft API request after another. + private static final long RETRY_BACKOFF_MS = 60_000L; // Member-management menu layout (a 5-row, 45-slot custom menu, index = row*9 + col, // both 0-indexed): // col 0 (slots 0,9,18,27,36): invite member / — / back button / guild reward @@ -501,6 +505,7 @@ private void logRewardsSlotDiagnostics(String reason) { private volatile String rank = ""; private volatile Map members = emptyMembers(); private volatile long lastRefresh = 0L; + private volatile long lastAttempt = 0L; private volatile boolean refreshing; /** Whether the linked player is a Chief or Owner (only they may gift). */ @@ -593,18 +598,22 @@ public Long memberJoined(String name) { return info == null ? null : info.joinedEpochMillis(); } - /** Refresh the rank + member list from the API if stale, off-thread (non-blocking). */ - public void ensureFresh(String playerName) { + /** + * Refresh the rank + member list from the API if stale, off-thread (non-blocking). + * Synchronized because it is also reached from the websocket thread. + */ + public synchronized void ensureFresh(String playerName) { if (playerName != null && !playerName.isBlank()) { selfName = playerName; } long now = System.currentTimeMillis(); - if (refreshing || (now - lastRefresh < REFRESH_INTERVAL_MS && !members.isEmpty())) { + if (refreshing || (now - lastRefresh < REFRESH_INTERVAL_MS && !members.isEmpty()) || now - lastAttempt < RETRY_BACKOFF_MS) { return; } if (playerName == null || playerName.isBlank()) { return; } + lastAttempt = now; refreshing = true; worker.submit(() -> { try { From 62ab7553eae63a758db4f98613f19b20c0940cbb Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:15 +0400 Subject: [PATCH 2/4] fix(raid): ignore chat lines containing a colon in ready detection The ready/start patterns match anywhere in a line, so guild chat like "food is ready!" counted as a player readying and "I would like to start a raid!" reset the tracker. Wynncraft's own lines never contain a colon; player chat always does. --- src/tel/eden/mod/chat/RaidReadyTracker.java | 4 +++- test/tel/eden/mod/chat/RaidReadyTrackerTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/tel/eden/mod/chat/RaidReadyTracker.java b/src/tel/eden/mod/chat/RaidReadyTracker.java index 29d750e..152a96d 100644 --- a/src/tel/eden/mod/chat/RaidReadyTracker.java +++ b/src/tel/eden/mod/chat/RaidReadyTracker.java @@ -68,7 +68,9 @@ private RaidReadyTracker() { public static void onSystemChat(Component message) { String text = ChatText.normalizeWhitespace(message.getString()); - if (text.isEmpty()) { + // Wynncraft's ready/start lines never contain a colon; player chat always does + // ("name: message"), so this keeps "food is ready!" in guild chat from counting. + if (text.isEmpty() || text.indexOf(':') >= 0) { return; } diff --git a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java index 653bd4a..8ea729e 100644 --- a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java +++ b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java @@ -73,6 +73,19 @@ void resetsOnNewRaidPromptAndMarksStarterReady() { assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("playerleader")); } + @Test + void ignoresPlayerChatContainingReadyPhrases() { + RaidReadyTracker.onSystemChat(Component.literal("YoureMomGood is ready!")); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + + // Guild/party chat is "name: message"; none of these may count or reset anything. + RaidReadyTracker.onSystemChat(Component.literal("[Eden] Bob: dinner is ready!")); + RaidReadyTracker.onSystemChat(Component.literal("Alice: I would like to start The Canyon Colossus!")); + RaidReadyTracker.onSystemChat(Component.literal("Carol: YoureMomGood is no longer ready!")); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("youremomgood")); + } + @Test void parsesPawnComponentColorsCorrectly() { // 2 ready (green), 1 unready (gray), 1 empty (dark gray) From 0f787520a107a40808b65562029137aced58a12b Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:21 +0400 Subject: [PATCH 3/4] fix(emote): drop duplicate click sounds on emote page buttons Vanilla buttons already play the click sound; the extra playDownSound calls made add/delete/set-active/randomize click twice. --- src/tel/eden/mod/emote/EmoteConfigScreen.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/tel/eden/mod/emote/EmoteConfigScreen.java b/src/tel/eden/mod/emote/EmoteConfigScreen.java index 521e2b0..858a81f 100644 --- a/src/tel/eden/mod/emote/EmoteConfigScreen.java +++ b/src/tel/eden/mod/emote/EmoteConfigScreen.java @@ -185,9 +185,6 @@ private void addPage() { } pages.add(newPage); loadPageFromMemory(pages.size() - 1); - if (this.minecraft != null && addPageBtn != null) { - addPageBtn.playDownSound(this.minecraft.getSoundManager()); - } } private void deleteCurrentPage() { @@ -204,17 +201,11 @@ private void deleteCurrentPage() { } int nextIndex = Math.min(currentPageIndex, pages.size() - 1); loadPageFromMemory(nextIndex); - if (this.minecraft != null && deletePageBtn != null) { - deletePageBtn.playDownSound(this.minecraft.getSoundManager()); - } } private void toggleActivePage() { activePageIndex = currentPageIndex; updateHeaderWidgets(); - if (this.minecraft != null && activeToggleBtn != null) { - activeToggleBtn.playDownSound(this.minecraft.getSoundManager()); - } } private void previousPage() { @@ -293,9 +284,6 @@ private void randomizeCurrentPage() { slotButtons.get(i).setValue(favorites[i]); } } - if (this.minecraft != null && randomizeBtn != null) { - randomizeBtn.playDownSound(this.minecraft.getSoundManager()); - } } @Override From 248f50cf0427208e3f58b7651dd46e86792d27fc Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:26 +0400 Subject: [PATCH 4/4] fix(war): keep the war board across world swaps and drop queuers that never get a timer A world swap fires JOIN while the bridge socket stays open, and the bot only sends the board to new connections, so wiping it on JOIN left queuer and goer heads gone until the next board change. JOIN now clears only this world's sidebar tracking; disconnect still clears everything. Board-owned queuers now follow the board (a dropped one is removed instead of being kept by putIfAbsent), and local-only queuers (chat reports, unechoed attacks) are dropped after 30s with no attack timer. --- src/tel/eden/mod/EdenModClient.java | 7 +- src/tel/eden/mod/war/AttackTimerMenu.java | 94 ++++++++++++++++++- .../tel/eden/mod/war/AttackTimerMenuTest.java | 80 ++++++++++++++++ 3 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 test/tel/eden/mod/war/AttackTimerMenuTest.java diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 21e98e6..443ca7b 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -655,13 +655,14 @@ public void release() { guildLogSync.onWorldJoin(); partyCommandGeneration.incrementAndGet(); loginPending = true; - // Fresh connection: drop the packet-captured scoreboard and all war-board state - // so a previous world/session's timers, defence, and heads never linger. + // Fresh connection: drop the packet-captured scoreboard and this world's timer + // tracking. The war board itself survives a world swap (the bridge socket stays + // open and keeps it current); disconnect() clears it fully. ScoreboardCapture.reset(); RaidPartyTracker.reset(); AllianceMenuScraper.reset(); GuildMenuScraper.reset(); - AttackTimerMenu.reset(); + AttackTimerMenu.onWorldJoin(); tel.eden.mod.war.radiance.RadianceTracker.onJoin(); guildAllyManager.requestCancel(); // Connect if applicable; the backend reports link/membership standing via diff --git a/src/tel/eden/mod/war/AttackTimerMenu.java b/src/tel/eden/mod/war/AttackTimerMenu.java index 216cf8a..03597fc 100644 --- a/src/tel/eden/mod/war/AttackTimerMenu.java +++ b/src/tel/eden/mod/war/AttackTimerMenu.java @@ -84,6 +84,18 @@ private AttackTimerMenu() { private static final Map defenseConflict = new HashMap<>(); /** Who queued each attack (from the {@code warBoard} broadcast or local detection). */ private static final Map queuers = new HashMap<>(); + /** + * Territories whose queuer only this client knows of (a chat report, or an attack the + * board hasn't echoed yet). Board-owned queuers follow the board; these are kept across + * board updates until the board names a queuer for them or they go stale. + */ + private static final java.util.Set localQueuers = new java.util.HashSet<>(); + // When each local-only queuer was first seen with no attack timer on the sidebar. + private static final Map localQueuerNoTimerSince = new HashMap<>(); + // How long a local-only queuer may sit with no attack timer before it is dropped: the + // timer appears on the sidebar as soon as an attack is queued, so one that never shows + // (or has since ended unseen) was never a real queue. + private static final long LOCAL_QUEUER_NO_TIMER_MS = 30_000L; /** Who is heading to each territory (from the {@code warBoard} broadcast). */ private static final Map> going = new HashMap<>(); /** Territories that had an attack timer last tick, to detect when one ends. */ @@ -161,6 +173,7 @@ public static void reportQueued(String territory, String name, String uuid) { inside = territory.equals(TerritoryData.territoryAt(pos.getX(), pos.getZ())); } queuers.put(territory, new WarQueuer(name, uuid, inside)); + localQueuers.add(territory); } /** @@ -221,7 +234,15 @@ public static void updateBoard(List entries) { going.clear(); scrapedDefenses.clear(); defenseConflict.clear(); - Map oldQueuers = new HashMap<>(queuers); + // Only local-only queuers survive a board update; a board-owned one the board no + // longer lists was dropped by the backend (its timer ended) and must go here too. + Map localOnly = new HashMap<>(); + for (String territory : localQueuers) { + WarQueuer queuer = queuers.get(territory); + if (queuer != null) { + localOnly.put(territory, queuer); + } + } queuers.clear(); for (WarBoardEntry entry : entries) { if (!entry.defense().isEmpty()) { @@ -237,7 +258,11 @@ public static void updateBoard(List entries) { queuers.put(entry.territory(), entry.queuer()); } } - oldQueuers.forEach(queuers::putIfAbsent); + // A territory the board now names a queuer for is board-owned from here on. + localOnly.keySet().removeIf(queuers::containsKey); + localQueuers.retainAll(localOnly.keySet()); + localQueuerNoTimerSince.keySet().retainAll(localQueuers); + queuers.putAll(localOnly); } /** @@ -277,6 +302,8 @@ public static void onTick(BridgeWebSocketClient socket) { missingSince.remove(territory); chatDefenses.remove(territory); queuers.remove(territory); + localQueuers.remove(territory); + localQueuerNoTimerSince.remove(territory); lastReportedInsideByTerritory.remove(territory); pendingInsideByTerritory.remove(territory); pendingSinceByTerritory.remove(territory); @@ -285,9 +312,31 @@ public static void onTick(BridgeWebSocketClient socket) { } } previousActive = active; + dropStaleLocalQueuers(current, now); reportInside(socket); } + /** + * Drop a local-only queuer once its territory has shown no attack timer for + * {@link #LOCAL_QUEUER_NO_TIMER_MS}: a chat report for a territory nobody queued, or a + * war that ended while this client was on another world, would otherwise never clear. + */ + private static void dropStaleLocalQueuers(java.util.Set current, long now) { + for (java.util.Iterator it = localQueuers.iterator(); it.hasNext();) { + String territory = it.next(); + if (current.contains(territory)) { + localQueuerNoTimerSince.remove(territory); + continue; + } + long since = localQueuerNoTimerSince.computeIfAbsent(territory, t -> now); + if (now - since >= LOCAL_QUEUER_NO_TIMER_MS) { + it.remove(); + localQueuerNoTimerSince.remove(territory); + queuers.remove(territory); + } + } + } + /** * If this client marked itself heading to a territory or queued it, report whether * it's standing inside it (only on a change), so its head border flips green/red for @@ -374,13 +423,35 @@ private static java.util.Set myRelevantTerritories(String myUuid) { return set; } - /** Clear all local war-board state (on world change / disconnect). */ + /** + * A world swap keeps the bridge socket open, and the backend only sends the board to a + * new connection, so the board state (queuers, goers, scraped defence) is kept here: the + * live socket keeps it current. Only state read from this world's sidebar is cleared. + * {@link #reset()} still clears everything on disconnect. + */ + public static void onWorldJoin() { + chatDefenses.clear(); + previousActive.clear(); + missingSince.clear(); + localQueuerNoTimerSince.clear(); + clickBoxes.clear(); + headBoxes.clear(); + soonestTerritory = null; + // Re-report inside status from this world's position. + lastReportedInsideByTerritory.clear(); + pendingInsideByTerritory.clear(); + pendingSinceByTerritory.clear(); + } + + /** Clear all local war-board state (on disconnect). */ public static void reset() { chatDefenses.clear(); scrapedDefenses.clear(); defenseConflict.clear(); going.clear(); queuers.clear(); + localQueuers.clear(); + localQueuerNoTimerSince.clear(); previousActive.clear(); missingSince.clear(); clickBoxes.clear(); @@ -834,4 +905,21 @@ private static int clamp(int value, int size, int screen) { private static String stripCodes(String text) { return text.replaceAll("§.", ""); } + + static void putLocalQueuerForTesting(String territory, WarQueuer queuer) { + queuers.put(territory, queuer); + localQueuers.add(territory); + } + + static WarQueuer queuerForTesting(String territory) { + return queuers.get(territory); + } + + static List goingForTesting(String territory) { + return going.get(territory); + } + + static void dropStaleLocalQueuersForTesting(java.util.Set current, long now) { + dropStaleLocalQueuers(current, now); + } } diff --git a/test/tel/eden/mod/war/AttackTimerMenuTest.java b/test/tel/eden/mod/war/AttackTimerMenuTest.java new file mode 100644 index 0000000..a928d07 --- /dev/null +++ b/test/tel/eden/mod/war/AttackTimerMenuTest.java @@ -0,0 +1,80 @@ +package tel.eden.mod.war; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tel.eden.mod.net.WarBoardEntry; +import tel.eden.mod.net.WarGoer; +import tel.eden.mod.net.WarQueuer; + +class AttackTimerMenuTest { + private static final WarQueuer ALICE = new WarQueuer("Alice", "00000000-0000-0000-0000-00000000000a", false); + private static final WarQueuer BOB = new WarQueuer("Bob", "00000000-0000-0000-0000-00000000000b", false); + + @BeforeEach + void setUp() { + AttackTimerMenu.reset(); + } + + @Test + void boardOwnedQueuerDropsWhenTheBoardStopsListingIt() { + AttackTimerMenu.updateBoard(List.of(new WarBoardEntry("Detlas", "", false, ALICE, List.of()))); + assertEquals(ALICE, AttackTimerMenu.queuerForTesting("Detlas")); + + AttackTimerMenu.updateBoard(List.of()); + assertNull(AttackTimerMenu.queuerForTesting("Detlas")); + } + + @Test + void localQueuerSurvivesBoardUpdatesUntilTheBoardOwnsIt() { + AttackTimerMenu.putLocalQueuerForTesting("Detlas", ALICE); + AttackTimerMenu.updateBoard(List.of()); + assertEquals(ALICE, AttackTimerMenu.queuerForTesting("Detlas")); + + // The board's (first-come) queuer wins, and from then on the board owns the entry. + AttackTimerMenu.updateBoard(List.of(new WarBoardEntry("Detlas", "", false, BOB, List.of()))); + assertEquals(BOB, AttackTimerMenu.queuerForTesting("Detlas")); + AttackTimerMenu.updateBoard(List.of()); + assertNull(AttackTimerMenu.queuerForTesting("Detlas")); + } + + @Test + void localQueuerWithNoTimerIsDroppedAfterTheGracePeriod() { + AttackTimerMenu.putLocalQueuerForTesting("Detlas", ALICE); + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of(), 1_000L); + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of(), 30_999L); + assertNotNull(AttackTimerMenu.queuerForTesting("Detlas")); + + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of(), 31_000L); + assertNull(AttackTimerMenu.queuerForTesting("Detlas")); + } + + @Test + void localQueuerWithATimerOnTheSidebarIsKept() { + AttackTimerMenu.putLocalQueuerForTesting("Detlas", ALICE); + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of(), 1_000L); + // The timer shows up: the no-timer clock restarts. + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of("Detlas"), 20_000L); + AttackTimerMenu.dropStaleLocalQueuersForTesting(Set.of(), 40_000L); + assertNotNull(AttackTimerMenu.queuerForTesting("Detlas")); + } + + @Test + void worldJoinKeepsTheBoardButResetClearsIt() { + WarGoer goer = new WarGoer("Carol", "00000000-0000-0000-0000-00000000000c", false); + AttackTimerMenu.updateBoard(List.of(new WarBoardEntry("Detlas", "High", false, ALICE, List.of(goer)))); + + AttackTimerMenu.onWorldJoin(); + assertEquals(ALICE, AttackTimerMenu.queuerForTesting("Detlas")); + assertEquals(List.of(goer), AttackTimerMenu.goingForTesting("Detlas")); + + AttackTimerMenu.reset(); + assertNull(AttackTimerMenu.queuerForTesting("Detlas")); + assertNull(AttackTimerMenu.goingForTesting("Detlas")); + } +}