diff --git a/resources/assets/edenmod/textures/catplush/cartiye.png b/resources/assets/edenmod/textures/catplush/cartiye.png index 1c76086..fde8265 100644 Binary files a/resources/assets/edenmod/textures/catplush/cartiye.png and b/resources/assets/edenmod/textures/catplush/cartiye.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepanger.png b/resources/assets/edenmod/textures/catplush/meepanger.png index 34151c2..98ca6ea 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepanger.png and b/resources/assets/edenmod/textures/catplush/meepanger.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepbleh.png b/resources/assets/edenmod/textures/catplush/meepbleh.png index 49ad94c..870be29 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepbleh.png and b/resources/assets/edenmod/textures/catplush/meepbleh.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepblush.png b/resources/assets/edenmod/textures/catplush/meepblush.png index 805bb6a..fa9fb47 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepblush.png and b/resources/assets/edenmod/textures/catplush/meepblush.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepconfused.png b/resources/assets/edenmod/textures/catplush/meepconfused.png index e48df14..c144b41 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepconfused.png and b/resources/assets/edenmod/textures/catplush/meepconfused.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepcross.png b/resources/assets/edenmod/textures/catplush/meepcross.png index dd492f1..209e429 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepcross.png and b/resources/assets/edenmod/textures/catplush/meepcross.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepdizzy.png b/resources/assets/edenmod/textures/catplush/meepdizzy.png index e369169..3c84bd5 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepdizzy.png and b/resources/assets/edenmod/textures/catplush/meepdizzy.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepnerd.png b/resources/assets/edenmod/textures/catplush/meepnerd.png index 9cce487..4f2d967 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepnerd.png and b/resources/assets/edenmod/textures/catplush/meepnerd.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsilent.png b/resources/assets/edenmod/textures/catplush/meepsilent.png index 4db1b24..1de900f 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsilent.png and b/resources/assets/edenmod/textures/catplush/meepsilent.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsleep.png b/resources/assets/edenmod/textures/catplush/meepsleep.png index e75b71d..4f0f9f3 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsleep.png and b/resources/assets/edenmod/textures/catplush/meepsleep.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsmile.png b/resources/assets/edenmod/textures/catplush/meepsmile.png index c0a23fb..e13f77c 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsmile.png and b/resources/assets/edenmod/textures/catplush/meepsmile.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsob.png b/resources/assets/edenmod/textures/catplush/meepsob.png index e54abf2..c36af22 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsob.png and b/resources/assets/edenmod/textures/catplush/meepsob.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsorry.png b/resources/assets/edenmod/textures/catplush/meepsorry.png index 64c280e..d93bfa2 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsorry.png and b/resources/assets/edenmod/textures/catplush/meepsorry.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepstarstruck.png b/resources/assets/edenmod/textures/catplush/meepstarstruck.png index fa4210a..88bd2c6 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepstarstruck.png and b/resources/assets/edenmod/textures/catplush/meepstarstruck.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepsurprised.png b/resources/assets/edenmod/textures/catplush/meepsurprised.png index 98d0cc8..8fffcd0 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepsurprised.png and b/resources/assets/edenmod/textures/catplush/meepsurprised.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepunamused.png b/resources/assets/edenmod/textures/catplush/meepunamused.png index b6b6026..e775ebf 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepunamused.png and b/resources/assets/edenmod/textures/catplush/meepunamused.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepwink.png b/resources/assets/edenmod/textures/catplush/meepwink.png index aed76af..7afcdf6 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepwink.png and b/resources/assets/edenmod/textures/catplush/meepwink.png differ diff --git a/resources/assets/edenmod/textures/catplush/meepworried.png b/resources/assets/edenmod/textures/catplush/meepworried.png index 511d5a0..7ccf457 100644 Binary files a/resources/assets/edenmod/textures/catplush/meepworried.png and b/resources/assets/edenmod/textures/catplush/meepworried.png differ diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 75fe081..f01dbcb 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -141,6 +141,9 @@ public final class EdenModClient implements ClientModInitializer { t.setDaemon(true); return t; }); + // Invalidates delayed party commands when the client changes server/world, so invites + // queued for one session can never be delivered to the next connection. + private final java.util.concurrent.atomic.AtomicInteger partyCommandGeneration = new java.util.concurrent.atomic.AtomicInteger(); // Deterministic per-occurrence index so rapid identical bank events stay distinct. // Short window: only the current burst counts, so stale earlier deposits can't // make different clients assign divergent seqs (which duplicated Discord posts). @@ -198,8 +201,9 @@ public String liveDiscordRank() { private boolean onWynncraft; // Mutated on the inbound-message path and read from GUI screens on the render - // thread; copy-on-write keeps those cross-thread reads from racing the writes. - private final java.util.List knownParties = new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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. + private volatile java.util.List knownParties = java.util.List.of(); // Latest aspects-owed list from the backend, read by AspectsPayoutScreen on the // render thread. Each reply replaces the whole list, so publishing an immutable // one through a volatile field keeps readers from ever seeing a half-applied @@ -331,7 +335,20 @@ public void openCenteredEmotePicker() { /** An immutable snapshot of the currently known parties, safe to read off-thread. */ public java.util.List knownParties() { - return java.util.List.copyOf(knownParties); + return knownParties; + } + + private synchronized void updateKnownParty(String event, PartyInfo party) { + java.util.List updated = new java.util.ArrayList<>(knownParties); + updated.removeIf(existing -> existing.id() == party.id()); + if (!event.equals("closed")) { + updated.add(party); + } + knownParties = java.util.List.copyOf(updated); + } + + private synchronized void replaceKnownParties(java.util.List parties) { + knownParties = java.util.List.copyOf(parties); } /** Members owed aspects, highest first, as of the last backend reply. */ @@ -426,6 +443,7 @@ public void onInitializeClient() { }); ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { + 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. @@ -731,10 +749,7 @@ public void onRewardDeductReply(String target, String rewardKind, int amount, in @Override public void onPartyUpdate(String event, String actor, PartyInfo party, String color) { - knownParties.removeIf(p -> p.id() == party.id()); - if (!event.equals("closed")) { - knownParties.add(party); - } + updateKnownParty(event, party); // Auto-announce party activity only when the player has the // (default-on) party feed enabled. if (!config.partyAnnounce) { @@ -752,8 +767,7 @@ public void onPartyUpdate(String event, String actor, PartyInfo party, String co @Override public void onPartyList(java.util.List parties, String color) { - knownParties.clear(); - knownParties.addAll(parties); + replaceKnownParties(parties); displayColored(color, () -> PartyFormatter.listing(parties)); } @@ -1271,7 +1285,11 @@ public void createInGameParty(List invites) { /** Send one server command after {@code delayMs}, on the client thread. */ private void sendServerCommandLater(String command, long delayMs) { + int generation = partyCommandGeneration.get(); partyCommandExecutor.schedule(() -> Minecraft.getInstance().execute(() -> { + if (partyCommandGeneration.get() != generation) { + return; + } var connection = Minecraft.getInstance().getConnection(); if (connection != null) { connection.sendCommand(command); @@ -1938,6 +1956,7 @@ private void onBridgeConnected() { } private synchronized void disconnect() { + partyCommandGeneration.incrementAndGet(); onWynncraft = false; loginPending = false; bridgeStatus = BridgeStatus.UNKNOWN; diff --git a/src/tel/eden/mod/chat/ChatText.java b/src/tel/eden/mod/chat/ChatText.java index aeb89ef..2e61fb8 100644 --- a/src/tel/eden/mod/chat/ChatText.java +++ b/src/tel/eden/mod/chat/ChatText.java @@ -195,6 +195,15 @@ private record MetaChar(char value, String hover, String insertion) { * shapes are cleanly matchable. */ static String normalize(String raw) { + return normalizeWhitespace(raw).replaceAll("\\s+([,.:;!?])", "$1"); + } + + /** + * Strip private-use glyph spam and control characters and collapse whitespace + * without changing punctuation spacing. Use this for user-authored text, where a + * space before {@code :shortcode:} or punctuation may be intentional. + */ + static String normalizeWhitespace(String raw) { if (raw == null || raw.isBlank()) { return ""; } @@ -213,7 +222,7 @@ static String normalize(String raw) { out.appendCodePoint(codePoint); previousWasSpace = false; } - return out.toString().trim().replaceAll("\\s+([,.:;!?])", "$1").replaceAll(" {2,}", " "); + return out.toString().trim(); } /** Whether the code point is a control/format/private-use/surrogate/unassigned char. */ diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index af8b6ae..1c1cc98 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -197,7 +197,7 @@ public static Component pill(String label, String content, Integer colorRgb) { /** The "replyTo (excerpt)" segment: name in green, inline quote gray, full quote on hover. */ private static MutableComponent replyTarget(String replyTo, String replyExcerpt) { MutableComponent segment = Component.literal(replyTo).withStyle(ChatFormatting.GREEN); - String quote = replyExcerpt == null ? "" : ChatText.normalize(replyExcerpt); + String quote = replyExcerpt == null ? "" : ChatText.normalizeWhitespace(replyExcerpt); if (!quote.isEmpty()) { String shown = quote.length() > EXCERPT_MAX ? quote.substring(0, EXCERPT_MAX).strip() + "…" : quote; Style hover = Style.EMPTY.withColor(ChatFormatting.DARK_GRAY).withHoverEvent(new HoverEvent.ShowText(Component.literal(quote))); diff --git a/src/tel/eden/mod/chat/EmoteRegistry.java b/src/tel/eden/mod/chat/EmoteRegistry.java index 249aa71..9bec30e 100644 --- a/src/tel/eden/mod/chat/EmoteRegistry.java +++ b/src/tel/eden/mod/chat/EmoteRegistry.java @@ -7,8 +7,8 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import net.minecraft.network.chat.FontDescription; import net.minecraft.resources.Identifier; import tel.eden.mod.EdenLogger; @@ -33,7 +33,6 @@ public final class EmoteRegistry { private static volatile Map emotes; private static volatile FontDescription font; - private static volatile boolean failedToLoad; private EmoteRegistry() { } @@ -70,7 +69,7 @@ private static void ensureLoaded() { } private static void load() { - Map loaded = new ConcurrentHashMap<>(); + Map loaded = new HashMap<>(); String fontId = DEFAULT_FONT_ID; try (InputStream in = EmoteRegistry.class.getResourceAsStream(MANIFEST_PATH)) { if (in == null) { @@ -101,4 +100,4 @@ private static void load() { font = new FontDescription.Resource(Identifier.parse(fontId)); emotes = Collections.unmodifiableMap(loaded); } -} \ No newline at end of file +} diff --git a/src/tel/eden/mod/chat/GuildChatParser.java b/src/tel/eden/mod/chat/GuildChatParser.java index 0dd8519..7ebc041 100644 --- a/src/tel/eden/mod/chat/GuildChatParser.java +++ b/src/tel/eden/mod/chat/GuildChatParser.java @@ -70,7 +70,10 @@ public static Optional parseSender(Component message) { return Optional.empty(); } List segments = collect(message); - String cleaned = ChatText.normalize(concat(segments)); + // Preserve punctuation spacing in the user-authored body. The stricter shared + // normalizer removes spaces before ':'/'!' for machine-parsed announcements, + // which would turn "hello :emote:" into "hello:emote:" in bridge payloads. + String cleaned = ChatText.normalizeWhitespace(concat(segments)); Matcher matcher = CHAT_PATTERN.matcher(cleaned); if (!matcher.find()) { return Optional.empty(); diff --git a/src/tel/eden/mod/chat/OccurrenceSequencer.java b/src/tel/eden/mod/chat/OccurrenceSequencer.java index 9350595..08049c7 100644 --- a/src/tel/eden/mod/chat/OccurrenceSequencer.java +++ b/src/tel/eden/mod/chat/OccurrenceSequencer.java @@ -2,6 +2,7 @@ import java.util.ArrayDeque; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; /** @@ -34,10 +35,8 @@ public OccurrenceSequencer(long windowMillis) { /** Record an occurrence of {@code signature} and return its index in the window. */ public synchronized int next(String signature) { long now = System.currentTimeMillis(); + purgeExpired(now); ArrayDeque times = seen.computeIfAbsent(signature, k -> new ArrayDeque<>()); - while (!times.isEmpty() && now - times.peekFirst() > windowMillis) { - times.pollFirst(); - } // A near-instant repeat is the same line emitted twice; reuse its index so the // backend dedups it instead of treating it as a second deposit. if (!times.isEmpty() && now - times.peekLast() < REEMIT_GUARD_MS) { @@ -46,4 +45,18 @@ public synchronized int next(String signature) { times.addLast(now); return times.size(); } + + /** Drop expired occurrences and their signatures so unique chat lines cannot accumulate forever. */ + private void purgeExpired(long now) { + Iterator> iterator = seen.values().iterator(); + while (iterator.hasNext()) { + ArrayDeque times = iterator.next(); + while (!times.isEmpty() && now - times.peekFirst() > windowMillis) { + times.pollFirst(); + } + if (times.isEmpty()) { + iterator.remove(); + } + } + } } diff --git a/src/tel/eden/mod/chat/RaidCompletion.java b/src/tel/eden/mod/chat/RaidCompletion.java index 9b47d19..dc86b1c 100644 --- a/src/tel/eden/mod/chat/RaidCompletion.java +++ b/src/tel/eden/mod/chat/RaidCompletion.java @@ -14,4 +14,7 @@ * from {@code "+633m Guild Experience"}); kept verbatim for display only */ public record RaidCompletion(List party, String raidName, int aspects, int emeralds, String guildExp) { + public RaidCompletion { + party = List.copyOf(party); + } } diff --git a/src/tel/eden/mod/chat/ShoutParser.java b/src/tel/eden/mod/chat/ShoutParser.java index 480eaad..6b29275 100644 --- a/src/tel/eden/mod/chat/ShoutParser.java +++ b/src/tel/eden/mod/chat/ShoutParser.java @@ -14,7 +14,8 @@ * * *

The rank badge between the name and {@code shouts:} is a - * custom-font glyph stripped by {@link ChatText#normalize}; the name is hover-resolved. + * custom-font glyph stripped by {@link ChatText#normalizeWhitespace}; the name is + * hover-resolved. */ public final class ShoutParser { // The name group excludes ':' so a copied shout pasted into another channel — which @@ -43,7 +44,7 @@ public static Optional parse(Component message) { if (!isCandidate(message)) { return Optional.empty(); } - Matcher matcher = SHOUT.matcher(ChatText.normalize(message.getString())); + Matcher matcher = SHOUT.matcher(ChatText.normalizeWhitespace(message.getString())); if (!matcher.matches()) { return Optional.empty(); } @@ -59,7 +60,7 @@ public static Optional shouterRealName(Component message) { if (!isCandidate(message)) { return Optional.empty(); } - Matcher matcher = SHOUT.matcher(ChatText.normalize(message.getString())); + Matcher matcher = SHOUT.matcher(ChatText.normalizeWhitespace(message.getString())); if (!matcher.matches()) { return Optional.empty(); } diff --git a/src/tel/eden/mod/gui/PartyListScreen.java b/src/tel/eden/mod/gui/PartyListScreen.java index 15fe872..512ce5b 100644 --- a/src/tel/eden/mod/gui/PartyListScreen.java +++ b/src/tel/eden/mod/gui/PartyListScreen.java @@ -53,9 +53,14 @@ private void requestPartyList() { private void refreshPartySnapshot() { lastKnownParties.clear(); lastKnownParties.addAll(mod.knownParties()); + lastKnownParties.sort(java.util.Comparator.comparing(PartyListScreen::isFull)); scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, lastKnownParties.size() - VISIBLE_ROWS))); } + private static boolean isFull(PartyInfo party) { + return party.size() >= party.max(); + } + @Override public void tick() { super.tick(); diff --git a/src/tel/eden/mod/item/CustomItem.java b/src/tel/eden/mod/item/CustomItem.java index d036080..30921c3 100644 --- a/src/tel/eden/mod/item/CustomItem.java +++ b/src/tel/eden/mod/item/CustomItem.java @@ -13,6 +13,11 @@ * under, and no {@code edenmod:} model has to exist for it. */ public record CustomItem(String type, List names, List lores, String texture, boolean labelOnly) { + public CustomItem { + names = List.copyOf(names); + lores = List.copyOf(lores); + } + /** A rule that swaps the item's model to {@code edenmod:} and labels it. */ public CustomItem(String type, List names, List lores, String texture) { this(type, names, lores, texture, false); diff --git a/src/tel/eden/mod/item/DecodedItem.java b/src/tel/eden/mod/item/DecodedItem.java index b2de7fd..4e6a201 100644 --- a/src/tel/eden/mod/item/DecodedItem.java +++ b/src/tel/eden/mod/item/DecodedItem.java @@ -14,6 +14,9 @@ * @param powderSlots the number of powder slots on the item */ public record DecodedItem(String name, String tier, int tierColor, String type, float overallPercent, List identifications, int powderSlots) { + public DecodedItem { + identifications = List.copyOf(identifications); + } /** Whether an overall roll quality is present (gear with variable stats). */ public boolean hasOverall() { diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 00f6336..349d071 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -150,6 +150,7 @@ public interface SessionAuthenticator { private volatile WebSocket socket; private volatile boolean running; private int backoffSeconds = 1; + private boolean reconnectScheduled; private final AtomicBoolean authChallengeSeen = new AtomicBoolean(false); private volatile String pendingAuthUsername; private int sessionVerifyRetries = 0; @@ -194,6 +195,7 @@ public synchronized void start() { public synchronized void close() { running = false; WebSocket current = socket; + socket = null; if (current != null) { current.sendClose(WebSocket.NORMAL_CLOSURE, "client closing"); } @@ -221,11 +223,13 @@ public void sendGuildChat(String username, String nickname, String message, int * Relay the guild's alliance list, read whole from the in-game Diplomacy menu. The * backend replaces its stored list with this, so it is only ever sent for a menu the * mod parsed in full. + * + * @return whether the message was queued on a connected socket */ - public void sendGuildAlliances(java.util.List guilds, java.util.List guildTags) { + public boolean sendGuildAlliances(java.util.List guilds, java.util.List guildTags) { WebSocket current = socket; if (current == null) { - return; + return false; } JsonObject obj = new JsonObject(); obj.addProperty("type", "guildAlliances"); @@ -242,6 +246,7 @@ public void sendGuildAlliances(java.util.List guilds, java.util.List members) * Report a territory's defence rating scraped from the {@code /guild attack} menu. * The backend caches it and broadcasts it to every member's attack-timer HUD, so a * member who never opened the menu still sees the freshest defence intel. + * + * @return whether the message was queued on a connected socket */ - public void sendWarDefense(String territory, String defense) { + public boolean sendWarDefense(String territory, String defense) { WebSocket current = socket; if (current == null) { - return; + return false; } JsonObject obj = new JsonObject(); obj.addProperty("type", "warDefense"); obj.addProperty("territory", territory); obj.addProperty("defense", defense); current.sendText(obj.toString(), true); + return true; } /** @@ -703,16 +711,12 @@ private void connect() { LOGGER.warn("Bridge WebSocket rejected: HTTP {}", status); // 4xx = permanent rejection; only 401 (bad JWT) reaches here now that // version/membership errors are sent as application-level messages. - running = false; - sink.onConnectionRejected("http_" + status); + rejectConnection("http_" + status); return; } LOGGER.warn("Bridge WebSocket connect failed: {}", error.toString()); - scheduleReconnect(); + scheduleReconnect(null); } else { - authChallengeSeen.set(false); - pendingAuthUsername = null; - socket = ws; LOGGER.info("Bridge WebSocket connected; awaiting session challenge"); // The socket is open but not yet trusted: the server sends an // authChallenge, and only on authOk do we reset backoff and hand the @@ -721,14 +725,45 @@ private void connect() { }); } - private void scheduleReconnect() { + private synchronized void scheduleReconnect(WebSocket disconnected) { + // Ignore terminal callbacks from an older socket. Without this identity check, a + // delayed onClose can clear a replacement connection that is already authenticating. + if (disconnected != null && socket != disconnected) { + return; + } socket = null; - if (!running) { + if (!running || reconnectScheduled) { return; } + reconnectScheduled = true; int delay = backoffSeconds; backoffSeconds = Math.min(backoffSeconds * 2, MAX_BACKOFF_SECONDS); - scheduler.schedule(this::connect, delay, TimeUnit.SECONDS); + scheduler.schedule(this::runScheduledReconnect, delay, TimeUnit.SECONDS); + } + + private void runScheduledReconnect() { + synchronized (this) { + reconnectScheduled = false; + if (!running) { + return; + } + } + connect(); + } + + /** Permanently stop this client after a server rejection and release its executor. */ + private void rejectConnection(String code) { + WebSocket current; + synchronized (this) { + running = false; + current = socket; + socket = null; + scheduler.shutdownNow(); + } + if (current != null) { + current.sendClose(WebSocket.NORMAL_CLOSURE, "connection rejected"); + } + sink.onConnectionRejected(code); } private final class Listener implements WebSocket.Listener { @@ -736,6 +771,19 @@ private final class Listener implements WebSocket.Listener { @Override public void onOpen(WebSocket webSocket) { + boolean accepted; + synchronized (BridgeWebSocketClient.this) { + accepted = running && (socket == null || socket == webSocket); + if (accepted) { + authChallengeSeen.set(false); + pendingAuthUsername = null; + socket = webSocket; + } + } + if (!accepted) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "stale connection"); + return; + } webSocket.request(1); } @@ -754,14 +802,14 @@ public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean @Override public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { LOGGER.info("Bridge WebSocket closed ({}): {}", statusCode, reason); - scheduleReconnect(); + scheduleReconnect(webSocket); return null; } @Override public void onError(WebSocket webSocket, Throwable error) { LOGGER.warn("Bridge WebSocket error: {}", error.toString()); - scheduleReconnect(); + scheduleReconnect(webSocket); } } @@ -813,8 +861,7 @@ private void handlePayload(String payload) { // Leave running=true so the imminent onClose schedules a reconnect. } else { LOGGER.warn("Bridge rejected connection: {}", code); - running = false; - sink.onConnectionRejected(code); + rejectConnection(code); } } default -> { diff --git a/src/tel/eden/mod/net/PartyInfo.java b/src/tel/eden/mod/net/PartyInfo.java index 992273c..088e4d3 100644 --- a/src/tel/eden/mod/net/PartyInfo.java +++ b/src/tel/eden/mod/net/PartyInfo.java @@ -7,6 +7,10 @@ * event or a {@code partyListReply}). {@code members} is host-first. */ public record PartyInfo(int id, String raid, String host, List members, int max, String note) { + public PartyInfo { + members = List.copyOf(members); + } + /** Current occupancy (e.g. {@code 2} of {@code max}). */ public int size() { return members.size(); diff --git a/src/tel/eden/mod/net/WarBoardEntry.java b/src/tel/eden/mod/net/WarBoardEntry.java index 29a9e64..ec10a71 100644 --- a/src/tel/eden/mod/net/WarBoardEntry.java +++ b/src/tel/eden/mod/net/WarBoardEntry.java @@ -8,4 +8,7 @@ * ratings ({@code conflict}), and who is heading there. */ public record WarBoardEntry(String territory, String defense, boolean conflict, List going) { + public WarBoardEntry { + going = List.copyOf(going); + } } diff --git a/src/tel/eden/mod/war/AllianceMenuScraper.java b/src/tel/eden/mod/war/AllianceMenuScraper.java index 7abfe5c..9e21b00 100644 --- a/src/tel/eden/mod/war/AllianceMenuScraper.java +++ b/src/tel/eden/mod/war/AllianceMenuScraper.java @@ -60,6 +60,7 @@ private AllianceMenuScraper() { // the backend somehow missed is never withheld for matching what we last sent. private static List lastSent = List.of(); private static boolean menuOpen; + private static boolean disconnectedWarningLogged; // The reading being waited on, and when it first appeared. A reading is only relayed // once it has read the same for ALLIANCE_STABLE_MS, which is what keeps a half-delivered // menu from replacing the stored list with a shorter one. @@ -71,6 +72,7 @@ private AllianceMenuScraper() { public static void onTick(Minecraft mc) { if (!(mc.screen instanceof AbstractContainerScreen screen) || !isDiplomacyTitle(screen)) { menuOpen = false; + disconnectedWarningLogged = false; return; } AbstractContainerMenu menu = screen.getMenu(); @@ -118,26 +120,32 @@ public static void onTick(Minecraft mc) { if (!firstReadOfThisMenu && allies.equals(lastSent)) { return; } - lastSent = allies; BridgeWebSocketClient socket = EdenModClient.instance().socket(); if (socket == null) { - LOGGER.warn("Read {} allies but the bridge is not connected: {}", allies.size(), allies); + if (!disconnectedWarningLogged) { + LOGGER.warn("Read {} allies but the bridge is not connected: {}", allies.size(), allies); + disconnectedWarningLogged = true; + } return; } - LOGGER.info("Alliance list read ({}): {}", allies.size(), allies); + disconnectedWarningLogged = false; List names = new ArrayList<>(); List tags = new ArrayList<>(); for (Ally ally : allies) { names.add(ally.name()); tags.add(ally.tag()); } - socket.sendGuildAlliances(names, tags); + if (socket.sendGuildAlliances(names, tags)) { + lastSent = allies; + LOGGER.info("Alliance list read ({}): {}", allies.size(), allies); + } } /** Forget the last relayed alliance list (world change / disconnect). */ public static void reset() { lastSent = List.of(); menuOpen = false; + disconnectedWarningLogged = false; pendingAllies = List.of(); pendingSince = 0; } diff --git a/src/tel/eden/mod/war/AttackMenuScraper.java b/src/tel/eden/mod/war/AttackMenuScraper.java index c3b2b56..1f03e54 100644 --- a/src/tel/eden/mod/war/AttackMenuScraper.java +++ b/src/tel/eden/mod/war/AttackMenuScraper.java @@ -30,23 +30,23 @@ private AttackMenuScraper() { private static final String TITLE_PREFIX = "Attacking:"; private static final Pattern DEFENSE = Pattern.compile("Territory Defences:\\s*(.+)"); - // Last (territory, rating) sent, so we only transmit on a change. Reset whenever the - // attack menu isn't open, so reopening it always re-reports (covers a backend TTL - // expiry mid-war). - private static String lastTerritory = ""; - private static String lastDefense = ""; + // Track the last locally applied and successfully sent readings separately. This avoids + // repeated local work while still retrying a reading that arrived during a reconnect. + // Reset whenever the menu closes so reopening it always re-reports (covers backend TTL). + private static String lastObservedTerritory = ""; + private static String lastObservedDefense = ""; + private static String lastSentTerritory = ""; + private static String lastSentDefense = ""; /** Client-thread tick: if the attack menu is open, scrape + relay slot 13's defence. */ public static void onTick(Minecraft mc) { if (!(mc.screen instanceof AbstractContainerScreen screen)) { - lastTerritory = ""; - lastDefense = ""; + resetLastReading(); return; } String territory = attackTargetTitle(screen); if (territory == null) { - lastTerritory = ""; - lastDefense = ""; + resetLastReading(); return; } AbstractContainerMenu menu = screen.getMenu(); @@ -54,18 +54,28 @@ public static void onTick(Minecraft mc) { return; } String defense = defenceFromLore(menu.getSlot(DEFENSE_SLOT).getItem()); - if (defense == null || (territory.equals(lastTerritory) && defense.equals(lastDefense))) { + if (defense == null) { return; } - lastTerritory = territory; - lastDefense = defense; - AttackTimerMenu.reportScrapedDefense(territory, defense); + if (!territory.equals(lastObservedTerritory) || !defense.equals(lastObservedDefense)) { + lastObservedTerritory = territory; + lastObservedDefense = defense; + AttackTimerMenu.reportScrapedDefense(territory, defense); + } BridgeWebSocketClient socket = EdenModClient.instance().socket(); - if (socket != null) { - socket.sendWarDefense(territory, defense); + if (socket != null && (!territory.equals(lastSentTerritory) || !defense.equals(lastSentDefense)) && socket.sendWarDefense(territory, defense)) { + lastSentTerritory = territory; + lastSentDefense = defense; } } + private static void resetLastReading() { + lastObservedTerritory = ""; + lastObservedDefense = ""; + lastSentTerritory = ""; + lastSentDefense = ""; + } + /** The territory named in an open "Attacking: <territory>" menu, or null. */ private static String attackTargetTitle(AbstractContainerScreen screen) { String title = strip(screen.getTitle().getString()); diff --git a/src/tel/eden/mod/war/TerritoryData.java b/src/tel/eden/mod/war/TerritoryData.java index 7d73a93..837918d 100644 --- a/src/tel/eden/mod/war/TerritoryData.java +++ b/src/tel/eden/mod/war/TerritoryData.java @@ -45,9 +45,9 @@ private TerritoryData() { private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).followRedirects(HttpClient.Redirect.NORMAL).build(); /** Territory name to bounding box {@code [minX, minZ, maxX, maxZ]} from the API. */ - private static volatile Map rects = new HashMap<>(); + private static volatile Map rects = Map.of(); /** Every territory name from the API, for war-detection validation. */ - private static volatile List names = new ArrayList<>(); + private static volatile List names = List.of(); /** Territory name to defense rating ("Very Low".."Very High"), from advancements. */ private static final Map defenses = new HashMap<>(); @@ -94,7 +94,8 @@ public static String territoryAt(int x, int z) { /** Bounding box {@code [minX, minZ, maxX, maxZ]} for a territory, or null. */ public static int[] rect(String territory) { - return rects.get(territory); + int[] rect = rects.get(territory); + return rect == null ? null : rect.clone(); } /** Centre {@code [x, z]} of a territory, or null if unknown. */ @@ -141,8 +142,10 @@ private static void parseApi(String body) { int ez = loc.getAsJsonArray("end").get(1).getAsInt(); newRects.put(name, new int[]{Math.min(sx, ex), Math.min(sz, ez), Math.max(sx, ex), Math.max(sz, ez)}); } - rects = newRects; - names = newNames; + // Publish complete immutable snapshots: the HTTP callback runs off-thread while + // render/tick consumers read these fields on the client thread. + rects = Map.copyOf(newRects); + names = List.copyOf(newNames); } private static void scrapeAdvancements() {