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/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/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 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 { 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/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) 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")); + } +}