Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/tel/eden/mod/EdenModClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/tel/eden/mod/chat/RaidReadyTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 0 additions & 12 deletions src/tel/eden/mod/emote/EmoteConfigScreen.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions src/tel/eden/mod/reward/GuildRewards.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -501,6 +505,7 @@ private void logRewardsSlotDiagnostics(String reason) {
private volatile String rank = "";
private volatile Map<String, MemberInfo> 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). */
Expand Down Expand Up @@ -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 {
Expand Down
94 changes: 91 additions & 3 deletions src/tel/eden/mod/war/AttackTimerMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ private AttackTimerMenu() {
private static final Map<String, Boolean> defenseConflict = new HashMap<>();
/** Who queued each attack (from the {@code warBoard} broadcast or local detection). */
private static final Map<String, WarQueuer> 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<String> localQueuers = new java.util.HashSet<>();
// When each local-only queuer was first seen with no attack timer on the sidebar.
private static final Map<String, Long> 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<String, List<WarGoer>> going = new HashMap<>();
/** Territories that had an attack timer last tick, to detect when one ends. */
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -221,7 +234,15 @@ public static void updateBoard(List<WarBoardEntry> entries) {
going.clear();
scrapedDefenses.clear();
defenseConflict.clear();
Map<String, WarQueuer> 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<String, WarQueuer> 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()) {
Expand All @@ -237,7 +258,11 @@ public static void updateBoard(List<WarBoardEntry> 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);
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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<String> current, long now) {
for (java.util.Iterator<String> 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
Expand Down Expand Up @@ -374,13 +423,35 @@ private static java.util.Set<String> 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();
Expand Down Expand Up @@ -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<WarGoer> goingForTesting(String territory) {
return going.get(territory);
}

static void dropStaleLocalQueuersForTesting(java.util.Set<String> current, long now) {
dropStaleLocalQueuers(current, now);
}
}
13 changes: 13 additions & 0 deletions test/tel/eden/mod/chat/RaidReadyTrackerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions test/tel/eden/mod/war/AttackTimerMenuTest.java
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading