From 06b1a6e8386080a3e5dc1181bc3c88d7da3f421e Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:37:24 +0300 Subject: [PATCH 1/5] War-suite review fixes: scope war-end to our own capture, marshal war chat onto client thread, fix timer sort + tower-bar bounds, shared TowerBar parser, per-tick attack scan, value-key item-texture cache --- src/tel/eden/mod/EdenModClient.java | 19 ++++++--- src/tel/eden/mod/item/CustomItemTextures.java | 37 ++++++++--------- src/tel/eden/mod/war/AttackTimerMenu.java | 37 +++++++++++++++-- src/tel/eden/mod/war/TowerBar.java | 41 +++++++++++++++++++ src/tel/eden/mod/war/WarDPS.java | 21 +++++----- src/tel/eden/mod/war/WarTracker.java | 22 ++-------- 6 files changed, 119 insertions(+), 58 deletions(-) create mode 100644 src/tel/eden/mod/war/TowerBar.java diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index f3fcb6b..7bd03ce 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -1667,10 +1667,15 @@ public void handleSystemChat(Component message) { DiscordChatFormatter.onServerChatLine(); if (onWynncraft) { // War chat cues (start countdown → attendance capture; end → HUD summary) - // work even while the bridge socket is down. + // work even while the bridge socket is down. handleSystemChat runs on the netty + // network thread (see ClientPacketListenerMixin), but the war subsystem's state + // is otherwise only touched on the client thread (tick + render), so marshal + // these onto it to avoid racing that state. String warLine = message.getString(); - WarTracker.onChat(warLine); - WarDPS.onChat(warLine); + Minecraft.getInstance().execute(() -> { + WarTracker.onChat(warLine); + WarDPS.onChat(warLine); + }); } BridgeWebSocketClient current = socket; if (!onWynncraft || current == null) { @@ -1794,8 +1799,12 @@ public void handleSystemChat(Component message) { Optional captured = GuildChatParser.parse(message); if (captured.isPresent() && config.warAttackTimers) { // Fold any " defense is " report (e.g. Wynntils') into the - // attack-timer HUD as fresher intel than the scraped advancement value. - AttackTimerMenu.intakeChat(captured.get().username(), captured.get().message()); + // attack-timer HUD as fresher intel than the scraped advancement value. Marshal + // onto the client thread — the HUD reads chatDefenses on the render thread, and + // this runs on the netty network thread. + String username = captured.get().username(); + String body = captured.get().message(); + Minecraft.getInstance().execute(() -> AttackTimerMenu.intakeChat(username, body)); } if (captured.isPresent() && chatRelay.shouldSend(captured.get())) { CapturedMessage line = captured.get(); diff --git a/src/tel/eden/mod/item/CustomItemTextures.java b/src/tel/eden/mod/item/CustomItemTextures.java index 03027a6..6a91b3f 100644 --- a/src/tel/eden/mod/item/CustomItemTextures.java +++ b/src/tel/eden/mod/item/CustomItemTextures.java @@ -50,10 +50,16 @@ private record Rule(List names, List lores, Identifier texture // Sentinel meaning "scanned, matched nothing" in the decision cache (never set on a stack). private static final Identifier NO_MATCH = Identifier.fromNamespaceAndPath(NAMESPACE, "no_match"); - // Texture decision cached by a cheap (name, lore) fingerprint: a bank full of identical - // items — and overlays that rebuild stacks each frame — then resolve once, not per frame. + // Texture decision cached by the item's (name, lore) components: a bank full of identical + // items — and overlays that rebuild stacks each frame — resolve once, not per frame. Keyed + // by the components' own value equality (no hash-collision risk) and computable without + // flattening the lore to strings, so a cache hit skips that per-item work too. private static final int CACHE_LIMIT = 4096; - private static final Map DECISION_CACHE = new HashMap<>(); + private static final Map DECISION_CACHE = new HashMap<>(); + + /** Value-equality cache key: the item's name and lore components. */ + private record CacheKey(Component name, ItemLore lore) { + } private static Map> compileRules() { Map> byType = new HashMap<>(); @@ -81,13 +87,10 @@ public static void applyCustomTexture(ItemStack stack) { if (Minecraft.getInstance().player == null || !isEligible(stack)) { return; } - String name = stack.getHoverName().getString(); - if (name.equals("Air")) { - return; - } - List lore = loreStrings(stack); - - long key = fingerprint(name, lore); + // Key off the components directly (cheap, no string flattening) and check the cache + // before doing any lore/name string work. + ItemLore loreComponent = stack.get(DataComponents.LORE); + CacheKey key = new CacheKey(stack.getHoverName(), loreComponent == null ? ItemLore.EMPTY : loreComponent); Identifier cached = DECISION_CACHE.get(key); if (cached != null) { if (cached != NO_MATCH) { @@ -95,7 +98,10 @@ public static void applyCustomTexture(ItemStack stack) { } return; } - Identifier match = resolve(stack, name, lore); + // Cache miss: now pay for flattening the lore to strings and scanning the rules. + String name = stack.getHoverName().getString(); + List lore = loreStrings(stack); + Identifier match = name.equals("Air") ? null : resolve(stack, name, lore); if (DECISION_CACHE.size() >= CACHE_LIMIT) { DECISION_CACHE.clear(); } @@ -175,15 +181,6 @@ private static String itemType(ItemStack stack, List lore) { return null; } - /** Cheap fingerprint of an item's identity for the decision cache. */ - private static long fingerprint(String name, List lore) { - long hash = name.hashCode(); - for (String line : lore) { - hash = hash * 31 + line.hashCode(); - } - return hash; - } - private static boolean nameMatches(List patterns, String name) { for (Pattern pattern : patterns) { if (!pattern.matcher(name).matches()) { diff --git a/src/tel/eden/mod/war/AttackTimerMenu.java b/src/tel/eden/mod/war/AttackTimerMenu.java index 81743a6..4d2e26b 100644 --- a/src/tel/eden/mod/war/AttackTimerMenu.java +++ b/src/tel/eden/mod/war/AttackTimerMenu.java @@ -48,6 +48,11 @@ private AttackTimerMenu() { /** Screen rectangles [x1,y1,x2,y2] of the last-rendered rows, for click hit-testing. */ private static final Map clickBoxes = new HashMap<>(); private static volatile String soonestTerritory = null; + // The scoreboard sidebar changes at most once per tick, but both the HUD (render()) and + // the territory wall (attackedTerritories()) query it every frame. Memoize the scan per + // game tick so the two callers, and multiple frames within a tick, share one parse. + private static long cachedAttacksTick = Long.MIN_VALUE; + private static List cachedAttacks = List.of(); private record Upcoming(String time, String territory) { } @@ -61,12 +66,23 @@ public static String soonestTerritory() { * scoreboard, independent of whether the HUD is shown). Empty when none. */ public static List attackedTerritories() { List out = new ArrayList<>(); - for (Upcoming attack : upcomingAttacks()) { + for (Upcoming attack : currentAttacks()) { out.add(attack.territory()); } return out; } + /** The upcoming attacks, scanned at most once per game tick (shared across callers). */ + private static List currentAttacks() { + Minecraft mc = Minecraft.getInstance(); + long tick = mc.level != null ? mc.level.getGameTime() : Long.MIN_VALUE; + if (tick != cachedAttacksTick) { + cachedAttacks = upcomingAttacks(); + cachedAttacksTick = tick; + } + return cachedAttacks; + } + /** Record a guild-reported defense rating (from the chat-defense intake). */ public static void reportDefense(String username, String territory, String defense) { chatDefenses.put(territory, new ChatDefenseInfo(username, territory, defense, System.currentTimeMillis())); @@ -100,7 +116,7 @@ public static void render(BridgeConfig config, GuiGraphics graphics) { soonestTerritory = null; return; } - List attacks = upcomingAttacks(); + List attacks = currentAttacks(); if (attacks.isEmpty()) { soonestTerritory = null; return; @@ -206,10 +222,25 @@ private static List upcomingAttacks() { } } } - attacks.sort((a, b) -> a.time().compareTo(b.time())); + // Sort by actual remaining time, not lexicographically: "9:05" is sooner than "10:02" + // even though '1' < '9' as text. + attacks.sort((a, b) -> Integer.compare(seconds(a.time()), seconds(b.time()))); return attacks; } + /** Parse an "M:SS"/"MM:SS" timer to total seconds; malformed sorts last. */ + private static int seconds(String time) { + String[] parts = time.split(":"); + if (parts.length != 2) { + return Integer.MAX_VALUE; + } + try { + return Integer.parseInt(parts[0].trim()) * 60 + Integer.parseInt(parts[1].trim()); + } catch (NumberFormatException e) { + return Integer.MAX_VALUE; + } + } + /** * The visible text of a sidebar line. Servers (Wynncraft included) render sidebar * text through the score holder's team prefix/suffix, so the bare owner name is not diff --git a/src/tel/eden/mod/war/TowerBar.java b/src/tel/eden/mod/war/TowerBar.java new file mode 100644 index 0000000..ff7999c --- /dev/null +++ b/src/tel/eden/mod/war/TowerBar.java @@ -0,0 +1,41 @@ +package tel.eden.mod.war; + +import java.util.Arrays; + +/** + * Parses Wynncraft's war-tower boss-bar text + * ("[TAG] Some Territory Tower - ❤ ... - ☠ ..."). + * + *

Shared by {@link WarDPS} (tower stats) and {@link WarTracker} (attendance territory + * tag) so both derive the territory the same way, and only one parse needs updating if + * Wynncraft changes the bar format. Input is expected already colour-/glyph-cleaned (see + * {@link WarDPS#clean}). + */ +final class TowerBar { + private TowerBar() { + } + + /** Whether a cleaned boss-bar line is a war-tower bar. */ + static boolean isTowerBar(String cleaned) { + return cleaned.contains("Tower"); + } + + /** + * The territory name from a cleaned tower-bar line, or null if it doesn't parse. The name + * is the words between the leading "[TAG]" and the trailing "Tower" word (i.e. before the + * first " - " stat separator). + */ + static String territory(String cleaned) { + String[] words = cleaned.split(" "); + int firstDash = Arrays.asList(words).indexOf("-"); + if (firstDash < 2) { + return null; + } + StringBuilder name = new StringBuilder(); + for (int i = 1; i < firstDash - 1; i++) { + name.append(words[i]).append(" "); + } + String territory = name.toString().trim(); + return territory.isEmpty() ? null : territory; + } +} diff --git a/src/tel/eden/mod/war/WarDPS.java b/src/tel/eden/mod/war/WarDPS.java index 88542c5..bdf5d03 100644 --- a/src/tel/eden/mod/war/WarDPS.java +++ b/src/tel/eden/mod/war/WarDPS.java @@ -78,15 +78,17 @@ public static void onTick(BridgeConfig config) { } } - /** War-end detection from chat; may be called off-thread (display is marshalled). */ + /** War-end detection from chat. Called on the client thread (marshalled by the caller). */ public static void onChat(String rawLine) { if (warStartTime < 0 || System.currentTimeMillis() - lastTimeInWar > END_CHAT_WINDOW_MS) { return; } - // Match on key phrases (not the exact territory) so a slightly different result - // wording still fires. "contains" tolerates any guild/war prefix on the line. + // Our own win reads "You have taken control of from [Tag]!"; a different + // guild capturing a territory broadcasts server-wide as "[Tag] has taken control of + // !". Match the personal "You have taken control of" so another guild's + // capture never ends (or falsely reports) our war. String line = clean(rawLine); - if (line.contains("taken control of")) { + if (line.contains("You have taken control of")) { warEnded(true); } else if (line.contains("lost the war") || line.contains("canceled and refunded") || line.contains("failed to take control")) { warEnded(false); @@ -136,16 +138,13 @@ private static void parseTowerBar(String[] words, String fullText) { List wordList = Arrays.asList(words); int firstDash = wordList.indexOf("-"); int lastDash = wordList.lastIndexOf("-"); - if (firstDash < 2 || lastDash <= firstDash || lastDash + 3 >= words.length + 1) { + if (firstDash < 2 || lastDash <= firstDash || lastDash + 3 >= words.length) { return; } - StringBuilder name = new StringBuilder(); - for (int i = 1; i < firstDash - 1; i++) { - name.append(words[i]).append(" "); - } - if (!name.toString().equals(territoryName)) { + String name = TowerBar.territory(fullText); + if (name != null && !name.equals(territoryName)) { resetWar(); - territoryName = name.toString(); + territoryName = name; warStartTime = System.currentTimeMillis(); initialStats = fullText; } diff --git a/src/tel/eden/mod/war/WarTracker.java b/src/tel/eden/mod/war/WarTracker.java index 450b781..e5691ad 100644 --- a/src/tel/eden/mod/war/WarTracker.java +++ b/src/tel/eden/mod/war/WarTracker.java @@ -1,7 +1,6 @@ package tel.eden.mod.war; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -83,7 +82,7 @@ public static void setWeeklyWars(int wars) { weeklyWars = wars; } - /** The battle-start line opens the capture window; may be called off-thread. */ + /** The battle-start line opens the capture window. Called on the client thread. */ public static void onChat(String rawLine) { if (WarDPS.clean(rawLine).contains(WAR_START_PHRASE)) { capturing = true; @@ -165,8 +164,8 @@ private static String activeTowerTerritory() { var events = ((BossHealthOverlayAccessor) mc.gui.getBossOverlay()).edenmod$events(); for (LerpingBossEvent event : events.values()) { String text = WarDPS.clean(event.getName().getString()); - if (text.contains("Tower")) { - String territory = territoryFromBar(text); + if (TowerBar.isTowerBar(text)) { + String territory = TowerBar.territory(text); if (territory != null && TerritoryData.territoryNames().contains(territory)) { return territory; } @@ -187,21 +186,6 @@ private static void sampleNearbyFighters() { } } - /** Territory name from a tower bar ("[TAG] Some Territory Tower - ..."). */ - private static String territoryFromBar(String text) { - String head = text.split(" - ")[0]; - String[] bracketSplit = head.split("] "); - if (bracketSplit.length < 2) { - return null; - } - String[] words = bracketSplit[1].trim().split(" "); - if (words.length < 2) { - return null; - } - // Drop the trailing "Tower" word. - return String.join(" ", Arrays.copyOfRange(words, 0, words.length - 1)).trim(); - } - private static void reportWar(String territory) { Minecraft mc = Minecraft.getInstance(); List members = new ArrayList<>(); From dbc3ad62e366963cf1cea1644942a90c98817c88 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:03:18 +0300 Subject: [PATCH 2/5] War board + Wynntils-proof timers: defence scrape, who's-going heads, config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attack-timer HUD gains guild-shared coordination, all over the bridge websocket: - Defence scrape: reads slot 13 of the /guild attack GUI ("Territory Defences") and shares it so a member who never opened the menu still sees the freshest enemy-territory rating. Priority scrape > chat > advancement, except a recent chat report wins when scouts reported conflicting scrapes (the attacker's committed value breaks the tie). - Who's-going: right-click a timer row to mark yourself heading there (left-click still points the compass). Each member's HUD draws the goers' heads (capped at 5 + "+N"), IGN on hover from the tab list. Server-attributed identity. - Lifetime: all of it is authoritative only while the timer exists; when a territory's timer ends the mod tells the backend to clear it, and everything resets on world change / disconnect. - Configurable "Max attack-timer rows" slider (1-50) collapses a 30-40 war spree into a "+N more" footer; the wall and beacon still use the full list. Wynntils hides the guild war-timer scoreboard segment, so the timers are read from a shadow Scoreboard fed by ConnectionScoreboardMixin at the head of Connection.channelRead0 — upstream of the packet listener where Wynntils strips it — and reset each connection so nothing stale carries over. --- resources/edenmod.mixins.json | 1 + src/tel/eden/mod/EdenModClient.java | 21 ++ src/tel/eden/mod/config/BridgeConfig.java | 7 + src/tel/eden/mod/gui/BridgeConfigScreen.java | 32 ++ src/tel/eden/mod/mixin/ChatScreenMixin.java | 10 +- .../mod/mixin/ConnectionScoreboardMixin.java | 49 +++ .../eden/mod/net/BridgeWebSocketClient.java | 88 +++++ src/tel/eden/mod/net/WarBoardEntry.java | 11 + src/tel/eden/mod/net/WarGoer.java | 9 + src/tel/eden/mod/war/AttackMenuScraper.java | 96 ++++++ src/tel/eden/mod/war/AttackTimerMenu.java | 317 ++++++++++++++++-- src/tel/eden/mod/war/ScoreboardCapture.java | 148 ++++++++ 12 files changed, 763 insertions(+), 26 deletions(-) create mode 100644 src/tel/eden/mod/mixin/ConnectionScoreboardMixin.java create mode 100644 src/tel/eden/mod/net/WarBoardEntry.java create mode 100644 src/tel/eden/mod/net/WarGoer.java create mode 100644 src/tel/eden/mod/war/AttackMenuScraper.java create mode 100644 src/tel/eden/mod/war/ScoreboardCapture.java diff --git a/resources/edenmod.mixins.json b/resources/edenmod.mixins.json index 0204bf7..3aa03cb 100644 --- a/resources/edenmod.mixins.json +++ b/resources/edenmod.mixins.json @@ -8,6 +8,7 @@ "ChatScreenMixin", "ClientPacketListenerCommandAliasMixin", "ClientPacketListenerMixin", + "ConnectionScoreboardMixin", "GuiGraphicsMixin", "ItemRenderMixin", "TitleScreenMixin" diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 7bd03ce..4a6fbd8 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -39,13 +39,16 @@ import tel.eden.mod.net.BridgeWebSocketClient; import tel.eden.mod.net.PartyInfo; import tel.eden.mod.net.PendingEntry; +import tel.eden.mod.net.WarBoardEntry; import tel.eden.mod.net.WarCountEntry; import tel.eden.mod.reward.GuildRewards; import tel.eden.mod.update.UpdateChecker; import tel.eden.mod.update.UpdateInfo; import tel.eden.mod.update.UpdateInstaller; import tel.eden.mod.util.Wynncraft; +import tel.eden.mod.war.AttackMenuScraper; import tel.eden.mod.war.AttackTimerMenu; +import tel.eden.mod.war.ScoreboardCapture; import tel.eden.mod.war.BeaconManager; import tel.eden.mod.war.TerritoryData; import tel.eden.mod.war.TerritoryMenuKeybind; @@ -336,6 +339,10 @@ public void onInitializeClient() { ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { 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. + ScoreboardCapture.reset(); + AttackTimerMenu.reset(); // Capture whether this server is Wynncraft before evaluateGating() may call // disconnect(), which clears onWynncraft even when the player is still on // Wynncraft (e.g. expired JWT). remindIfUnlinked needs the real server state. @@ -416,6 +423,8 @@ private void onClientTick(Minecraft client) { TerritoryOutlineRenderer.onTick(client); WarDPS.onTick(config); WarTracker.onTick(); + AttackMenuScraper.onTick(client); + AttackTimerMenu.onTick(socket); tel.eden.mod.emote.EmoteWheel.onTick(client); tel.eden.mod.emote.UnlockedEmoteDetector.onTick(client); BridgeWebSocketClient current = socket; @@ -623,6 +632,14 @@ public void onWarCounts(int days, java.util.List entries, String displayColored(color, () -> DiscordChatFormatter.warCounts(days, entries, requester)); } + @Override + public void onWarBoard(java.util.List entries) { + // Marshal onto the client thread: the attack-timer maps are otherwise + // only touched there (render + the menu scraper), so this keeps all + // access single-threaded (this callback runs on the websocket thread). + Minecraft.getInstance().execute(() -> AttackTimerMenu.updateBoard(entries)); + } + @Override public void onConnectionRejected(String code) { pendingConnectionCode = code; @@ -1654,6 +1671,10 @@ private synchronized void disconnect() { tabCheckTick = 0; presenceTick = 0; pendingConnectionCode = null; + // Drop the captured scoreboard + war-board state so nothing carries into the next + // connection (stale timers/defence/heads). + ScoreboardCapture.reset(); + AttackTimerMenu.reset(); if (socket != null) { socket.close(); socket = null; diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 0d1657d..cf5681e 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -91,6 +91,12 @@ public String toString() { /** HUD list of upcoming territory attacks (scoreboard timers + defense ratings). */ public boolean warAttackTimers = true; + /** + * Max attack-timer rows shown before the rest collapse into a "+N more" footer (the + * soonest are kept). A full-scale war can queue 30-40+ territories. Range 1-50. + */ + public int warAttackTimerMaxRows = 14; + /** Green in-world beacon marking the soonest upcoming territory attack. */ public boolean warGreenBeacon = true; @@ -275,6 +281,7 @@ public static BridgeConfig load() { } config.emotePickerColumns = Math.max(1, Math.min(10, config.emotePickerColumns)); config.emotePickerRows = Math.max(1, Math.min(10, config.emotePickerRows)); + config.warAttackTimerMaxRows = Math.max(1, Math.min(50, config.warAttackTimerMaxRows)); config.imagePreviewSize = Math.max(1, Math.min(100, config.imagePreviewSize)); return config; } diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index 5ebd1a6..f88a4db 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -90,6 +90,8 @@ protected void init() { // --- Territory / war suite --- addToggleRow("Attack timers HUD", () -> config.warAttackTimers, v -> config.warAttackTimers = v, "On", "Off", true); + AttackTimerRowsSlider rowsSlider = new AttackTimerRowsSlider(CONTROL_W, 20); + addSliderRow("Max attack-timer rows", rowsSlider, rowsSlider::syncFromConfig, () -> config.warAttackTimerMaxRows = 14); addToggleRow("Green beacon at soonest war", () -> config.warGreenBeacon, v -> config.warGreenBeacon = v, "On", "Off", true); addToggleRow("War info overlay (DPS/EHP)", () -> config.warDpsHud, v -> config.warDpsHud = v, "On", "Off", true); addToggleRow("Weekly war count HUD", () -> config.warWeeklyCountHud, v -> config.warWeeklyCountHud = v, "On", "Off", false); @@ -455,4 +457,34 @@ protected void applyValue() { updateMessage(); } } + + private final class AttackTimerRowsSlider extends AbstractSliderButton { + private static final int MIN = 1; + private static final int MAX = 50; + + private AttackTimerRowsSlider(int width, int height) { + super(0, 0, width, height, Component.empty(), 0.0d); + syncFromConfig(); + } + + private void syncFromConfig() { + this.value = (config.warAttackTimerMaxRows - MIN) / (double) (MAX - MIN); + updateMessage(); + } + + @Override + protected void updateMessage() { + setMessage(Component.literal(config.warAttackTimerMaxRows + " rows")); + } + + @Override + protected void applyValue() { + int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); + if (snapped != config.warAttackTimerMaxRows) { + config.warAttackTimerMaxRows = snapped; + config.save(); + } + updateMessage(); + } + } } diff --git a/src/tel/eden/mod/mixin/ChatScreenMixin.java b/src/tel/eden/mod/mixin/ChatScreenMixin.java index f8cab3e..9444ba4 100644 --- a/src/tel/eden/mod/mixin/ChatScreenMixin.java +++ b/src/tel/eden/mod/mixin/ChatScreenMixin.java @@ -214,9 +214,10 @@ public abstract class ChatScreenMixin { @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) private void edenmod$handleAttackTimerClick(MouseButtonEvent event, boolean bl, CallbackInfoReturnable cir) { - // Runs regardless of the emote-tool settings: clicking an attack-timer row - // points Wynncraft's compass at that territory. - if (event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT && AttackTimerMenu.mouseClicked(EdenModClient.instance().config(), event.x(), event.y())) { + // Runs regardless of the emote-tool settings: left-clicking an attack-timer row + // points Wynncraft's compass at that territory; right-clicking marks you heading + // there (a head shared with the guild). + if (AttackTimerMenu.mouseClicked(EdenModClient.instance().config(), event.x(), event.y(), event.button())) { cir.setReturnValue(true); } } @@ -260,6 +261,9 @@ public abstract class ChatScreenMixin { @Inject(method = "render", at = @At("TAIL")) private void edenmod$renderChatOverlays(GuiGraphics graphics, int mouseX, int mouseY, float delta, CallbackInfo ci) { + // Hovering an attack-timer head shows that player's IGN (works regardless of the + // emote-tool settings, like the timer click). + AttackTimerMenu.renderGoerTooltip(graphics, mouseX, mouseY); if (!edenmod$isChatEmoteUiVisible() && !edenmod$isChatEmoteAutocompleteEnabled()) { edenmod$resetOverlayState(); return; diff --git a/src/tel/eden/mod/mixin/ConnectionScoreboardMixin.java b/src/tel/eden/mod/mixin/ConnectionScoreboardMixin.java new file mode 100644 index 0000000..9b3b9a2 --- /dev/null +++ b/src/tel/eden/mod/mixin/ConnectionScoreboardMixin.java @@ -0,0 +1,49 @@ +package tel.eden.mod.mixin; + +import io.netty.channel.ChannelHandlerContext; +import net.minecraft.client.Minecraft; +import net.minecraft.network.Connection; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundResetScorePacket; +import net.minecraft.network.protocol.game.ClientboundSetDisplayObjectivePacket; +import net.minecraft.network.protocol.game.ClientboundSetObjectivePacket; +import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket; +import net.minecraft.network.protocol.game.ClientboundSetScorePacket; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import tel.eden.mod.war.ScoreboardCapture; + +/** + * Mirrors the raw scoreboard packets into {@link ScoreboardCapture}'s shadow scoreboard. + * + *

Injected at the head of {@code Connection.channelRead0} — the netty entry + * point that later calls {@code packet.handle(listener)}. This runs strictly before any + * packet-listener mixin (where Wynntils strips the guild war-timer segment), so + * the war suite still sees those lines no matter what Wynntils does downstream. We only + * observe (never cancel), so vanilla and every other handler are unaffected. A low + * {@code priority} makes this injector run first even against another head injector. + * + *

{@code channelRead0} runs on the netty thread; the shadow scoreboard is only touched + * on the client thread (the HUD reads it there), so each apply is marshalled across. + */ +@Mixin(value = Connection.class, priority = 100) +public class ConnectionScoreboardMixin { + @Inject(method = "channelRead0(Lio/netty/channel/ChannelHandlerContext;Lnet/minecraft/network/protocol/Packet;)V", at = @At("HEAD")) + private void edenmod$captureScoreboard(ChannelHandlerContext context, Packet packet, CallbackInfo ci) { + // Cheap gate: the vast majority of packets are not scoreboard packets and fall + // straight through. Only capture the five that define the sidebar. + if (packet instanceof ClientboundSetScorePacket score) { + Minecraft.getInstance().execute(() -> ScoreboardCapture.apply(score)); + } else if (packet instanceof ClientboundResetScorePacket reset) { + Minecraft.getInstance().execute(() -> ScoreboardCapture.apply(reset)); + } else if (packet instanceof ClientboundSetObjectivePacket objective) { + Minecraft.getInstance().execute(() -> ScoreboardCapture.apply(objective)); + } else if (packet instanceof ClientboundSetDisplayObjectivePacket display) { + Minecraft.getInstance().execute(() -> ScoreboardCapture.apply(display)); + } else if (packet instanceof ClientboundSetPlayerTeamPacket team) { + Minecraft.getInstance().execute(() -> ScoreboardCapture.apply(team)); + } + } +} diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 8c80e1f..0492628 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -87,6 +87,13 @@ public interface MessageSink { * own row can be highlighted/cached. */ void onWarCounts(int days, java.util.List entries, String requester, String color); + + /** + * The live war board changed: each attacked territory's scraped defence rating + * (from someone's {@code /guild attack} menu) and who is heading there. A full + * snapshot — replace, don't merge, the who's-going state. + */ + void onWarBoard(java.util.List entries); } private final URI uri; @@ -321,6 +328,55 @@ public boolean sendWarAttended(String territory, java.util.List members) return true; } + /** + * 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. + */ + public void sendWarDefense(String territory, String defense) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "warDefense"); + obj.addProperty("territory", territory); + obj.addProperty("defense", defense); + current.sendText(obj.toString(), true); + } + + /** + * 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 + * current one clears it. The backend broadcasts the updated board to everyone. + */ + public void sendWarGoing(String territory) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "warGoing"); + obj.addProperty("territory", territory); + current.sendText(obj.toString(), true); + } + + /** + * Tell the backend a territory's attack timer has ended (no longer on the scoreboard), + * so it clears that territory's cached defence/conflict/who's-going and the next war on + * it starts fresh. + */ + public void sendWarTimerEnded(String territory) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "warTimerEnded"); + obj.addProperty("territory", territory); + current.sendText(obj.toString(), true); + } + /** Ask for the guild's per-member war counts over the last {@code days} days. */ public void sendWarCountsRequest(int days) { WebSocket current = socket; @@ -588,6 +644,7 @@ private void handlePayload(String payload) { case "gameFeedback" -> sink.onGameFeedback(get(obj, "message"), get(obj, "color")); case "pillMessage" -> sink.onPillMessage(get(obj, "label"), get(obj, "content"), get(obj, "color")); case "warCountsReply" -> sink.onWarCounts(getInt(obj, "days", 7), parseWarCounts(obj), get(obj, "requester"), get(obj, "color")); + case "warBoard" -> sink.onWarBoard(parseWarBoard(obj)); case "error" -> { String code = get(obj, "code"); LOGGER.warn("Bridge rejected connection: {}", code); @@ -606,6 +663,14 @@ private static String get(JsonObject obj, String key) { return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : ""; } + private static boolean getBool(JsonObject obj, String key) { + try { + return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); + } catch (RuntimeException e) { + return false; + } + } + private static int getInt(JsonObject obj, String key, int fallback) { try { return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsInt() : fallback; @@ -643,6 +708,29 @@ private static java.util.List parseWarCounts(JsonObject obj) { return out; } + private static java.util.List parseWarBoard(JsonObject obj) { + java.util.List out = new java.util.ArrayList<>(); + if (obj.has("territories") && obj.get("territories").isJsonArray()) { + for (var element : obj.get("territories").getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + JsonObject entry = element.getAsJsonObject(); + java.util.List going = new java.util.ArrayList<>(); + if (entry.has("going") && entry.get("going").isJsonArray()) { + for (var goerElement : entry.get("going").getAsJsonArray()) { + if (goerElement.isJsonObject()) { + JsonObject goer = goerElement.getAsJsonObject(); + going.add(new WarGoer(get(goer, "name"), get(goer, "uuid"))); + } + } + } + out.add(new WarBoardEntry(get(entry, "territory"), get(entry, "defense"), getBool(entry, "conflict"), going)); + } + } + return out; + } + private static java.util.List parsePendingEntries(JsonObject obj) { java.util.List out = new java.util.ArrayList<>(); if (obj.has("members") && obj.get("members").isJsonArray()) { diff --git a/src/tel/eden/mod/net/WarBoardEntry.java b/src/tel/eden/mod/net/WarBoardEntry.java new file mode 100644 index 0000000..29a9e64 --- /dev/null +++ b/src/tel/eden/mod/net/WarBoardEntry.java @@ -0,0 +1,11 @@ +package tel.eden.mod.net; + +import java.util.List; + +/** + * One territory's live war-board state (from the {@code warBoard} broadcast): the + * scraped defence rating ({@code ""} when unknown), whether scouts reported conflicting + * ratings ({@code conflict}), and who is heading there. + */ +public record WarBoardEntry(String territory, String defense, boolean conflict, List going) { +} diff --git a/src/tel/eden/mod/net/WarGoer.java b/src/tel/eden/mod/net/WarGoer.java new file mode 100644 index 0000000..dfb84a2 --- /dev/null +++ b/src/tel/eden/mod/net/WarGoer.java @@ -0,0 +1,9 @@ +package tel.eden.mod.net; + +/** + * One guild member heading to a territory (from the {@code warBoard} broadcast): + * their display name and Wynncraft uuid, used to draw their head on the attack-timer + * HUD. + */ +public record WarGoer(String name, String uuid) { +} diff --git a/src/tel/eden/mod/war/AttackMenuScraper.java b/src/tel/eden/mod/war/AttackMenuScraper.java new file mode 100644 index 0000000..c3b2b56 --- /dev/null +++ b/src/tel/eden/mod/war/AttackMenuScraper.java @@ -0,0 +1,96 @@ +package tel.eden.mod.war; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.ItemLore; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.net.BridgeWebSocketClient; + +/** + * Scrapes the enemy territory's defence rating from the {@code /guild attack} GUI. + * + *

That menu's title is "Attacking: <territory>" and slot 13 is the emerald + * ("Attack for free") whose lore reads "Territory Defences: <rating>". This is + * the freshest, most accurate defence source — the advancement scrape in + * {@link TerritoryData} only covers our own territories, not the enemy one + * we're about to hit. On a change the value is shown locally at once and reported to + * the backend, which broadcasts it to every member's {@link AttackTimerMenu}. + */ +public final class AttackMenuScraper { + private AttackMenuScraper() { + } + + private static final int DEFENSE_SLOT = 13; + 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 = ""; + + /** 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 = ""; + return; + } + String territory = attackTargetTitle(screen); + if (territory == null) { + lastTerritory = ""; + lastDefense = ""; + return; + } + AbstractContainerMenu menu = screen.getMenu(); + if (DEFENSE_SLOT >= menu.slots.size()) { + return; + } + String defense = defenceFromLore(menu.getSlot(DEFENSE_SLOT).getItem()); + if (defense == null || (territory.equals(lastTerritory) && defense.equals(lastDefense))) { + return; + } + lastTerritory = territory; + lastDefense = defense; + AttackTimerMenu.reportScrapedDefense(territory, defense); + BridgeWebSocketClient socket = EdenModClient.instance().socket(); + if (socket != null) { + socket.sendWarDefense(territory, defense); + } + } + + /** The territory named in an open "Attacking: <territory>" menu, or null. */ + private static String attackTargetTitle(AbstractContainerScreen screen) { + String title = strip(screen.getTitle().getString()); + if (!title.startsWith(TITLE_PREFIX)) { + return null; + } + String territory = title.substring(TITLE_PREFIX.length()).trim(); + return territory.isEmpty() ? null : territory; + } + + private static String defenceFromLore(ItemStack stack) { + ItemLore lore = stack.get(DataComponents.LORE); + if (lore == null) { + return null; + } + for (Component line : lore.lines()) { + Matcher matcher = DEFENSE.matcher(strip(line.getString())); + if (matcher.find()) { + return matcher.group(1).trim(); + } + } + return null; + } + + private static String strip(String text) { + return text.replaceAll("§.", "").trim(); + } +} diff --git a/src/tel/eden/mod/war/AttackTimerMenu.java b/src/tel/eden/mod/war/AttackTimerMenu.java index 4d2e26b..25dc052 100644 --- a/src/tel/eden/mod/war/AttackTimerMenu.java +++ b/src/tel/eden/mod/war/AttackTimerMenu.java @@ -4,22 +4,32 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.PlayerFaceRenderer; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.resources.DefaultPlayerSkin; import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.PlayerSkin; import net.minecraft.world.scores.DisplaySlot; import net.minecraft.world.scores.Objective; import net.minecraft.world.scores.PlayerScoreEntry; import net.minecraft.world.scores.PlayerTeam; import net.minecraft.world.scores.Scoreboard; +import org.lwjgl.glfw.GLFW; +import tel.eden.mod.EdenModClient; import tel.eden.mod.config.BridgeConfig; import tel.eden.mod.hud.HudLayout; import tel.eden.mod.hud.HudPanel; import tel.eden.mod.hud.RectangleElement; import tel.eden.mod.hud.TextElement; +import tel.eden.mod.net.BridgeWebSocketClient; +import tel.eden.mod.net.WarBoardEntry; +import tel.eden.mod.net.WarGoer; /** * HUD list of upcoming territory attacks read from the scoreboard sidebar, each row @@ -43,10 +53,29 @@ private AttackTimerMenu() { // A guild-chat defense report, e.g. "Detlas defense is High" (Wynntils' format). private static final Pattern DEFENSE_REPORT = Pattern.compile("^(.+?) defense is (Very Low|Low|Medium|High|Very High)\\b", Pattern.CASE_INSENSITIVE); + private static final int HEAD_SIZE = 8; + private static final int HEAD_GAP = 1; + // A war party caps at 5, so at most five distinct heads ever need showing. + private static final int MAX_HEADS = 5; + /** Latest fresher-than-scrape defense reports keyed by territory. */ private static final Map chatDefenses = new HashMap<>(); + /** + * Defence scraped from the {@code /guild attack} menu, keyed by territory — from this + * client's own scrape or the backend's {@code warBoard} broadcast of another member's. + * Normally preferred over the chat report; the chat report wins only on a conflict. + */ + private static final Map scrapedDefenses = new HashMap<>(); + /** Territories where scouts reported conflicting scraped ratings (from the board). */ + private static final Map defenseConflict = new HashMap<>(); + /** 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. */ + private static java.util.Set previousActive = new java.util.HashSet<>(); /** Screen rectangles [x1,y1,x2,y2] of the last-rendered rows, for click hit-testing. */ private static final Map clickBoxes = new HashMap<>(); + /** Screen rectangles of the last-rendered heads and the names to show on hover. */ + private static final List headBoxes = new ArrayList<>(); private static volatile String soonestTerritory = null; // The scoreboard sidebar changes at most once per tick, but both the HUD (render()) and // the territory wall (attackedTerritories()) query it every frame. Memoize the scan per @@ -57,6 +86,10 @@ private AttackTimerMenu() { private record Upcoming(String time, String territory) { } + /** A drawn head's screen rectangle and the names it represents (for hover tooltips). */ + private record HeadBox(int x1, int y1, int x2, int y2, List names) { + } + /** Territory with the earliest upcoming attack, or null. */ public static String soonestTerritory() { return soonestTerritory; @@ -110,8 +143,76 @@ private static String canonicalDefense(String defense) { }; } + /** Record a defence scraped locally from this client's {@code /guild attack} menu. */ + public static void reportScrapedDefense(String territory, String defense) { + scrapedDefenses.put(territory, canonicalDefense(defense)); + } + + /** + * Apply a full {@code warBoard} snapshot from the backend. A full replace (not a merge) + * of who's-going, scraped defence, and the conflict flag, so a territory the backend + * dropped (its timer ended) also drops here. + */ + public static void updateBoard(List entries) { + going.clear(); + scrapedDefenses.clear(); + defenseConflict.clear(); + for (WarBoardEntry entry : entries) { + if (!entry.defense().isEmpty()) { + scrapedDefenses.put(entry.territory(), canonicalDefense(entry.defense())); + } + if (entry.conflict()) { + defenseConflict.put(entry.territory(), Boolean.TRUE); + } + if (!entry.going().isEmpty()) { + going.put(entry.territory(), entry.going()); + } + } + } + + /** + * Detect attack timers that have just ended and clear their state. When a territory + * leaves the scoreboard (its timer expired), drop this client's chat report for it and + * tell the backend to clear the shared defence/conflict/who's-going, so the next war on + * that territory starts fresh. Skipped entirely when the whole sidebar is gone (a world + * switch / disconnect), so that never looks like every war ending at once. + */ + public static void onTick(BridgeWebSocketClient socket) { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard == null || scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR) == null) { + previousActive.clear(); + return; + } + java.util.Set current = new java.util.HashSet<>(); + for (Upcoming attack : currentAttacks()) { + current.add(attack.territory()); + } + for (String territory : previousActive) { + if (!current.contains(territory)) { + chatDefenses.remove(territory); + if (socket != null) { + socket.sendWarTimerEnded(territory); + } + } + } + previousActive = current; + } + + /** Clear all local war-board state (on world change / disconnect). */ + public static void reset() { + chatDefenses.clear(); + scrapedDefenses.clear(); + defenseConflict.clear(); + going.clear(); + previousActive.clear(); + clickBoxes.clear(); + headBoxes.clear(); + soonestTerritory = null; + } + public static void render(BridgeConfig config, GuiGraphics graphics) { clickBoxes.clear(); + headBoxes.clear(); if (!config.warAttackTimers) { soonestTerritory = null; return; @@ -127,16 +228,31 @@ public static void render(BridgeConfig config, GuiGraphics graphics) { Font font = mc.font; String currentTerritory = currentTerritory(); + // Cap the visible rows to the soonest configured max so a 30-40 war spree doesn't + // run the HUD off-screen; the rest collapse into a "+N more wars" footer. The + // territory wall and beacon still use the full, uncapped list. + int maxRows = Math.max(1, Math.min(50, config.warAttackTimerMaxRows)); + int overflowWars = Math.max(0, attacks.size() - maxRows); + List shown = overflowWars > 0 ? attacks.subList(0, maxRows) : attacks; + + // Per-row text width, so heads sit just past each row's text and the panel is + // widened to fit them (keeping the whole row — text and heads — clickable). List lines = new ArrayList<>(); - for (Upcoming attack : attacks) { - lines.add(rowText(attack, currentTerritory)); - } + int[] textWidths = new int[shown.size()]; int maxWidth = 0; - for (String line : lines) { - maxWidth = Math.max(maxWidth, font.width(stripCodes(line))); + for (int i = 0; i < shown.size(); i++) { + String line = rowText(shown.get(i), currentTerritory); + lines.add(line); + textWidths[i] = font.width(stripCodes(line)); + maxWidth = Math.max(maxWidth, textWidths[i] + headsWidth(going.get(shown.get(i).territory()))); } + String footer = overflowWars > 0 ? "§7+" + overflowWars + " more war" + (overflowWars == 1 ? "" : "s") : null; + if (footer != null) { + maxWidth = Math.max(maxWidth, font.width(stripCodes(footer))); + } + int rows = shown.size() + (footer != null ? 1 : 0); int width = maxWidth + 6; - int height = attacks.size() * ROW_HEIGHT + 4; + int height = rows * ROW_HEIGHT + 4; float scale = HudLayout.scale(config, PANEL); int x = clamp(HudLayout.x(config, PANEL, DEFAULT_X, DEFAULT_Y), Math.round(width * scale), mc.getWindow().getGuiScaledWidth()); int y = clamp(HudLayout.y(config, PANEL, DEFAULT_X, DEFAULT_Y), Math.round(height * scale), mc.getWindow().getGuiScaledHeight()); @@ -146,29 +262,152 @@ public static void render(BridgeConfig config, GuiGraphics graphics) { HudPanel panel = new HudPanel(); panel.add(new RectangleElement(0, 0, width, height, PANEL_BG)); int relY = 3; - for (int i = 0; i < attacks.size(); i++) { + for (int i = 0; i < shown.size(); i++) { panel.add(new TextElement(lines.get(i), 3, relY, 0xFFFFFFFF)); - clickBoxes.put(attacks.get(i).territory(), new int[]{x, Math.round(y + (relY - 1) * scale), Math.round(x + width * scale), Math.round(y + (relY + ROW_HEIGHT - 1) * scale)}); + clickBoxes.put(shown.get(i).territory(), new int[]{x, Math.round(y + (relY - 1) * scale), Math.round(x + width * scale), Math.round(y + (relY + ROW_HEIGHT - 1) * scale)}); relY += ROW_HEIGHT; } + if (footer != null) { + panel.add(new TextElement(footer, 3, relY, 0xFFFFFFFF)); + } graphics.pose().pushMatrix(); graphics.pose().translate(x, y); graphics.pose().scale(scale, scale); panel.draw(graphics); + // Heads draw over the panel, in the same transformed space, just past each row's text. + relY = 3; + for (int i = 0; i < shown.size(); i++) { + drawHeads(graphics, font, 3 + textWidths[i] + 2, relY, going.get(shown.get(i).territory()), x, y, scale); + relY += ROW_HEIGHT; + } graphics.pose().popMatrix(); } - /** Handle a click while the chat screen is open; true if a row was hit. */ - public static boolean mouseClicked(BridgeConfig config, double mouseX, double mouseY) { + /** + * Show a goer's IGN (or the overflow's names) when the mouse is over their head. + * Called while the chat screen is open — that's when a cursor exists and clicking + * already works, so hovering fits the same interaction. + */ + public static void renderGoerTooltip(GuiGraphics graphics, int mouseX, int mouseY) { + for (HeadBox box : headBoxes) { + if (mouseX >= box.x1() && mouseX <= box.x2() && mouseY >= box.y1() && mouseY <= box.y2()) { + List lines = new ArrayList<>(); + for (String name : box.names()) { + lines.add(net.minecraft.network.chat.Component.literal(name)); + } + graphics.setComponentTooltipForNextFrame(Minecraft.getInstance().font, lines, mouseX, mouseY); + return; + } + } + } + + /** Pixel width the heads (capped, plus a "+N" overflow) occupy after a row's text. */ + private static int headsWidth(List goers) { + if (goers == null || goers.isEmpty()) { + return 0; + } + int shown = Math.min(goers.size(), MAX_HEADS); + int width = 2 + shown * (HEAD_SIZE + HEAD_GAP); + int overflow = goers.size() - shown; + if (overflow > 0) { + width += Minecraft.getInstance().font.width("+" + overflow) + 2; + } + return width; + } + + /** + * Draw the going players' heads (capped at {@link #MAX_HEADS}, then "+N"), recording + * each head's screen-space rectangle (origin + relative*scale) for hover tooltips. + * {@code relX}/{@code relY} are panel-relative; {@code originX}/{@code originY}/{@code scale} + * map them to the screen for the boxes. + */ + private static void drawHeads(GuiGraphics graphics, Font font, int relX, int relY, List goers, int originX, int originY, float scale) { + if (goers == null || goers.isEmpty()) { + return; + } + Minecraft mc = Minecraft.getInstance(); + int shown = Math.min(goers.size(), MAX_HEADS); + for (int i = 0; i < shown; i++) { + int headRelX = relX + i * (HEAD_SIZE + HEAD_GAP); + PlayerFaceRenderer.draw(graphics, skinFor(mc, goers.get(i)), headRelX, relY, HEAD_SIZE); + headBoxes.add(headBox(headRelX, relY, HEAD_SIZE, HEAD_SIZE, originX, originY, scale, List.of(nameFor(mc, goers.get(i))))); + } + int overflow = goers.size() - shown; + if (overflow > 0) { + // +2 matches the gap headsWidth() reserves before the overflow counter. + int overflowRelX = relX + shown * (HEAD_SIZE + HEAD_GAP) + 2; + graphics.drawString(font, "+" + overflow, overflowRelX, relY, 0xFFFFFFFF); + List rest = new ArrayList<>(); + for (int i = shown; i < goers.size(); i++) { + rest.add(nameFor(mc, goers.get(i))); + } + headBoxes.add(headBox(overflowRelX, relY, font.width("+" + overflow), font.lineHeight, originX, originY, scale, rest)); + } + } + + /** A screen-space hover box from a panel-relative (x, y) run of {@code width}x{@code height}px. */ + private static HeadBox headBox(int relX, int relY, int width, int height, int originX, int originY, float scale, List names) { + return new HeadBox(Math.round(originX + relX * scale), Math.round(originY + relY * scale), Math.round(originX + (relX + width) * scale), Math.round(originY + (relY + height) * scale), names); + } + + /** A goer's current IGN from the tab list (fresh after a rename), else the board name. */ + private static String nameFor(Minecraft mc, WarGoer goer) { + try { + UUID uuid = UUID.fromString(goer.uuid()); + if (mc.getConnection() != null) { + PlayerInfo info = mc.getConnection().getPlayerInfo(uuid); + if (info != null) { + return info.getProfile().name(); + } + } + } catch (IllegalArgumentException ignored) { + // Fall through to the board-provided name. + } + return goer.name(); + } + + /** + * The skin to draw for a goer: an online guildmate's already-loaded skin from the + * tab list (no async, no Steve), falling back to the uuid's default skin. + */ + private static PlayerSkin skinFor(Minecraft mc, WarGoer goer) { + UUID uuid; + try { + uuid = UUID.fromString(goer.uuid()); + } catch (IllegalArgumentException e) { + return DefaultPlayerSkin.get(UUID.nameUUIDFromBytes(goer.name().getBytes())); + } + if (mc.getConnection() != null) { + PlayerInfo info = mc.getConnection().getPlayerInfo(uuid); + if (info != null) { + return info.getSkin(); + } + } + return DefaultPlayerSkin.get(uuid); + } + + /** + * Handle a click on a timer row while the chat screen is open; true if a row was hit. + * Left-click points Wynncraft's compass at the territory; right-click toggles this + * player's "heading here" marker (a head next to the row, shared with the guild). + */ + public static boolean mouseClicked(BridgeConfig config, double mouseX, double mouseY, int button) { if (!config.warAttackTimers) { return false; } for (Map.Entry entry : clickBoxes.entrySet()) { int[] box = entry.getValue(); if (mouseX >= box[0] && mouseX <= box[2] && mouseY >= box[1] && mouseY <= box[3]) { - int[] middle = TerritoryData.middle(entry.getKey()); - if (middle != null && Minecraft.getInstance().getConnection() != null) { - Minecraft.getInstance().getConnection().sendCommand("compass " + middle[0] + " " + middle[1]); + if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + BridgeWebSocketClient socket = EdenModClient.instance().socket(); + if (socket != null) { + socket.sendWarGoing(entry.getKey()); + } + } else { + int[] middle = TerritoryData.middle(entry.getKey()); + if (middle != null && Minecraft.getInstance().getConnection() != null) { + Minecraft.getInstance().getConnection().sendCommand("compass " + middle[0] + " " + middle[1]); + } } return true; } @@ -183,11 +422,31 @@ private static String rowText(Upcoming attack, String currentTerritory) { } private static String coloredDefense(String territory) { - // Prefer a recent guild-chat report (fresher intel) over the scraped value; - // show a single rating rather than both. + // Normal priority: attack-menu scrape > recent chat report > advancement scrape. + // Exception: when scouts reported conflicting scraped ratings (an open attack GUI + // freezes its value, so two scouts can disagree), the attacker's chat report + // ("Olux defense is High", posted as they commit the war) is the tie-breaker, so + // chat wins over scrape. All of this resets when the territory's timer ends. ChatDefenseInfo chat = chatDefenses.get(territory); - String defense = (chat != null && chat.isRecent()) ? chat.defense() : TerritoryData.defense(territory); - return colorFor(defense); + boolean chatOk = chat != null && chat.isRecent(); + String scraped = scrapedDefenses.get(territory); + boolean scrapeOk = scraped != null && !scraped.isEmpty(); + if (Boolean.TRUE.equals(defenseConflict.get(territory))) { + if (chatOk) { + return colorFor(chat.defense()); + } + if (scrapeOk) { + return colorFor(scraped); + } + } else { + if (scrapeOk) { + return colorFor(scraped); + } + if (chatOk) { + return colorFor(chat.defense()); + } + } + return colorFor(TerritoryData.defense(territory)); } private static String colorFor(String defense) { @@ -201,11 +460,10 @@ private static String colorFor(String defense) { } private static List upcomingAttacks() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null) { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard == null) { return List.of(); } - Scoreboard scoreboard = mc.level.getScoreboard(); Objective sidebar = scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR); if (sidebar == null) { return List.of(); @@ -254,15 +512,28 @@ private static String sidebarLineText(Scoreboard scoreboard, PlayerScoreEntry en return entry.ownerName().getString(); } + /** + * The scoreboard to read timer lines from: the packet-captured shadow when it carries + * a sidebar (so Wynntils hiding the vanilla segment can't blind us), else the game's + * own scoreboard as a fallback. + */ + private static Scoreboard sidebarScoreboard() { + if (ScoreboardCapture.hasSidebar()) { + return ScoreboardCapture.scoreboard(); + } + Minecraft mc = Minecraft.getInstance(); + return mc.level != null ? mc.level.getScoreboard() : null; + } + /** Diagnostic: dump every scoreboard slot/objective and its scores, to locate the * timer lines when they aren't in the standard sidebar. */ public static List debugSidebarLines() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null) { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard == null) { return List.of("(no world)"); } - Scoreboard scoreboard = mc.level.getScoreboard(); List out = new ArrayList<>(); + out.add("source: " + (ScoreboardCapture.hasSidebar() ? "captured shadow" : "vanilla scoreboard")); for (DisplaySlot slot : DisplaySlot.values()) { Objective shown = scoreboard.getDisplayObjective(slot); out.add("slot " + slot + " -> " + (shown == null ? "none" : shown.getName())); diff --git a/src/tel/eden/mod/war/ScoreboardCapture.java b/src/tel/eden/mod/war/ScoreboardCapture.java new file mode 100644 index 0000000..9fa69f3 --- /dev/null +++ b/src/tel/eden/mod/war/ScoreboardCapture.java @@ -0,0 +1,148 @@ +package tel.eden.mod.war; + +import net.minecraft.network.protocol.game.ClientboundResetScorePacket; +import net.minecraft.network.protocol.game.ClientboundSetDisplayObjectivePacket; +import net.minecraft.network.protocol.game.ClientboundSetObjectivePacket; +import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket; +import net.minecraft.network.protocol.game.ClientboundSetScorePacket; +import net.minecraft.world.scores.DisplaySlot; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.ScoreAccess; +import net.minecraft.world.scores.ScoreHolder; +import net.minecraft.world.scores.Scoreboard; +import net.minecraft.world.scores.criteria.ObjectiveCriteria; + +/** + * A shadow {@link Scoreboard} fed directly from the raw clientbound scoreboard packets, + * independent of the game's own scoreboard. + * + *

Wynntils hides the guild war-timer scoreboard segment: it processes the scoreboard + * inside the packet listener ({@code handleSetScore} & co.) and strips that + * segment, so by the time the war suite reads {@code level.getScoreboard()} the timer + * lines are gone. {@link tel.eden.mod.mixin.ConnectionScoreboardMixin} captures each + * scoreboard packet one level upstream — at the head of {@code Connection.channelRead0}, + * before {@code packet.handle(...)} ever dispatches to the listener — and feeds it here. + * Because this mirror is built from the wire and never touched by Wynntils, the timer + * lines survive. {@link AttackTimerMenu} reads this shadow when it carries a sidebar. + * + *

The apply methods replicate {@code ClientPacketListener}'s own scoreboard handlers + * exactly, so the shadow is byte-for-byte what the vanilla scoreboard would have been. + * All access is marshalled onto the client thread by the mixin, matching where the HUD + * reads it, so no locking is needed. + */ +public final class ScoreboardCapture { + private ScoreboardCapture() { + } + + private static Scoreboard shadow = new Scoreboard(); + + /** The shadow scoreboard (client thread only). */ + public static Scoreboard scoreboard() { + return shadow; + } + + /** Whether the shadow currently has a sidebar objective (i.e. it captured timer data). */ + public static boolean hasSidebar() { + return shadow.getDisplayObjective(DisplaySlot.SIDEBAR) != null; + } + + /** + * Drop everything captured so far (call on world change / disconnect). The shadow is a + * static singleton reused across connections; without this, a previous session's + * objectives/teams/scores linger — e.g. a stale sidebar makes {@link #hasSidebar()} + * stay true and old timers show after a world hop. + */ + public static void reset() { + shadow = new Scoreboard(); + } + + /** Apply a captured objective add/remove/change to the shadow. */ + public static void apply(ClientboundSetObjectivePacket packet) { + String name = packet.getObjectiveName(); + if (packet.getMethod() == ClientboundSetObjectivePacket.METHOD_ADD) { + shadow.addObjective(name, ObjectiveCriteria.DUMMY, packet.getDisplayName(), packet.getRenderType(), false, packet.getNumberFormat().orElse(null)); + return; + } + Objective objective = shadow.getObjective(name); + if (objective == null) { + return; + } + if (packet.getMethod() == ClientboundSetObjectivePacket.METHOD_REMOVE) { + shadow.removeObjective(objective); + } else if (packet.getMethod() == ClientboundSetObjectivePacket.METHOD_CHANGE) { + objective.setDisplayName(packet.getDisplayName()); + objective.setRenderType(packet.getRenderType()); + objective.setNumberFormat(packet.getNumberFormat().orElse(null)); + } + } + + /** Apply which objective occupies a display slot (e.g. the sidebar). */ + public static void apply(ClientboundSetDisplayObjectivePacket packet) { + String name = packet.getObjectiveName(); + Objective objective = name == null ? null : shadow.getObjective(name); + shadow.setDisplayObjective(packet.getSlot(), objective); + } + + /** Apply a single score update to the shadow. */ + public static void apply(ClientboundSetScorePacket packet) { + Objective objective = shadow.getObjective(packet.objectiveName()); + if (objective == null) { + return; + } + ScoreAccess access = shadow.getOrCreatePlayerScore(ScoreHolder.forNameOnly(packet.owner()), objective); + access.set(packet.score()); + access.display(packet.display().orElse(null)); + access.numberFormatOverride(packet.numberFormat().orElse(null)); + } + + /** Apply a score reset (one objective, or all when the name is null). */ + public static void apply(ClientboundResetScorePacket packet) { + ScoreHolder holder = ScoreHolder.forNameOnly(packet.owner()); + String objectiveName = packet.objectiveName(); + if (objectiveName == null) { + shadow.resetAllPlayerScores(holder); + return; + } + Objective objective = shadow.getObjective(objectiveName); + if (objective != null) { + shadow.resetSinglePlayerScore(holder, objective); + } + } + + /** Apply a team add/change/remove and its player join/leave to the shadow. */ + public static void apply(ClientboundSetPlayerTeamPacket packet) { + ClientboundSetPlayerTeamPacket.Action teamAction = packet.getTeamAction(); + PlayerTeam team; + if (teamAction == ClientboundSetPlayerTeamPacket.Action.ADD) { + team = shadow.addPlayerTeam(packet.getName()); + } else { + team = shadow.getPlayerTeam(packet.getName()); + if (team == null) { + return; + } + } + packet.getParameters().ifPresent(parameters -> { + team.setDisplayName(parameters.getDisplayName()); + team.setColor(parameters.getColor()); + team.unpackOptions(parameters.getOptions()); + team.setNameTagVisibility(parameters.getNametagVisibility()); + team.setCollisionRule(parameters.getCollisionRule()); + team.setPlayerPrefix(parameters.getPlayerPrefix()); + team.setPlayerSuffix(parameters.getPlayerSuffix()); + }); + ClientboundSetPlayerTeamPacket.Action playerAction = packet.getPlayerAction(); + if (playerAction == ClientboundSetPlayerTeamPacket.Action.ADD) { + for (String player : packet.getPlayers()) { + shadow.addPlayerToTeam(player, team); + } + } else if (playerAction == ClientboundSetPlayerTeamPacket.Action.REMOVE) { + for (String player : packet.getPlayers()) { + shadow.removePlayerFromTeam(player, team); + } + } + if (teamAction == ClientboundSetPlayerTeamPacket.Action.REMOVE) { + shadow.removePlayerTeam(team); + } + } +} From eb6bc43b7830b0c1267967b60f51432a575b9b8d Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:28:54 +0300 Subject: [PATCH 3/5] War heads: green/red going border, cross-world skin fetch, IGN on hover - Border on each goer's head: green once they're inside their marked territory, red while en route. This client reports its own inside-status (only on a border crossing) via warGoingInside; the border colour comes from the board's per-goer `inside` flag. - Cross-world skins: a goer on a different Wynncraft world instance isn't in our player list, so their profile has no textures and only a default skin is available. Resolve those by uuid from Mojang off-thread, cached, showing the default until it lands. Same-world goers still use the player list. --- .../eden/mod/net/BridgeWebSocketClient.java | 17 ++- src/tel/eden/mod/net/WarGoer.java | 6 +- src/tel/eden/mod/war/AttackTimerMenu.java | 119 +++++++++++++++++- 3 files changed, 133 insertions(+), 9 deletions(-) diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 0492628..3159d84 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -361,6 +361,21 @@ public void sendWarGoing(String territory) { current.sendText(obj.toString(), true); } + /** + * Report whether this player is currently inside the territory they marked heading to, + * so their head border shows green (inside) or red (en route) on every member's HUD. + */ + public void sendWarGoingInside(boolean inside) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "warGoingInside"); + obj.addProperty("inside", inside); + current.sendText(obj.toString(), true); + } + /** * Tell the backend a territory's attack timer has ended (no longer on the scoreboard), * so it clears that territory's cached defence/conflict/who's-going and the next war on @@ -721,7 +736,7 @@ private static java.util.List parseWarBoard(JsonObject obj) { for (var goerElement : entry.get("going").getAsJsonArray()) { if (goerElement.isJsonObject()) { JsonObject goer = goerElement.getAsJsonObject(); - going.add(new WarGoer(get(goer, "name"), get(goer, "uuid"))); + going.add(new WarGoer(get(goer, "name"), get(goer, "uuid"), getBool(goer, "inside"))); } } } diff --git a/src/tel/eden/mod/net/WarGoer.java b/src/tel/eden/mod/net/WarGoer.java index dfb84a2..f3bd92c 100644 --- a/src/tel/eden/mod/net/WarGoer.java +++ b/src/tel/eden/mod/net/WarGoer.java @@ -2,8 +2,8 @@ /** * One guild member heading to a territory (from the {@code warBoard} broadcast): - * their display name and Wynncraft uuid, used to draw their head on the attack-timer - * HUD. + * their display name, Wynncraft uuid, and whether they are currently inside that + * territory ({@code inside} — green head border when true, red while en route). */ -public record WarGoer(String name, String uuid) { +public record WarGoer(String name, String uuid, boolean inside) { } diff --git a/src/tel/eden/mod/war/AttackTimerMenu.java b/src/tel/eden/mod/war/AttackTimerMenu.java index 25dc052..da8c814 100644 --- a/src/tel/eden/mod/war/AttackTimerMenu.java +++ b/src/tel/eden/mod/war/AttackTimerMenu.java @@ -1,10 +1,16 @@ package tel.eden.mod.war; +import com.mojang.authlib.GameProfile; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.minecraft.client.Minecraft; @@ -21,6 +27,8 @@ import net.minecraft.world.scores.PlayerTeam; import net.minecraft.world.scores.Scoreboard; import org.lwjgl.glfw.GLFW; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import tel.eden.mod.EdenModClient; import tel.eden.mod.config.BridgeConfig; import tel.eden.mod.hud.HudLayout; @@ -57,6 +65,9 @@ private AttackTimerMenu() { private static final int HEAD_GAP = 1; // A war party caps at 5, so at most five distinct heads ever need showing. private static final int MAX_HEADS = 5; + // Head border: green once a goer is inside their target territory, red while en route. + private static final int BORDER_GREEN = 0xFF44FF44; + private static final int BORDER_RED = 0xFFFF5050; /** Latest fresher-than-scrape defense reports keyed by territory. */ private static final Map chatDefenses = new HashMap<>(); @@ -72,6 +83,10 @@ private AttackTimerMenu() { private static final Map> going = new HashMap<>(); /** Territories that had an attack timer last tick, to detect when one ends. */ private static java.util.Set previousActive = new java.util.HashSet<>(); + // Last inside-status this client reported for its own head, and whether it was + // heading anywhere, so it only sends on a change (enter/leave the target territory). + private static boolean wasGoing; + private static boolean lastReportedInside; /** Screen rectangles [x1,y1,x2,y2] of the last-rendered rows, for click hit-testing. */ private static final Map clickBoxes = new HashMap<>(); /** Screen rectangles of the last-rendered heads and the names to show on hover. */ @@ -196,6 +211,42 @@ public static void onTick(BridgeWebSocketClient socket) { } } previousActive = current; + reportInside(socket); + } + + /** + * If this client marked itself heading to a territory, report whether it's standing + * inside it (only on a change), so its head border flips green/red for everyone. The + * goer's own client is the authority on its position — no one else knows it. + */ + private static void reportInside(BridgeWebSocketClient socket) { + Minecraft mc = Minecraft.getInstance(); + String myTerritory = mc.player == null ? null : myGoingTerritory(mc.player.getUUID().toString()); + if (myTerritory == null) { + wasGoing = false; + return; + } + BlockPos pos = mc.player.blockPosition(); + boolean inside = myTerritory.equals(TerritoryData.territoryAt(pos.getX(), pos.getZ())); + if (!wasGoing || inside != lastReportedInside) { + wasGoing = true; + lastReportedInside = inside; + if (socket != null) { + socket.sendWarGoingInside(inside); + } + } + } + + /** The territory this client is currently marked as heading to (from the board), or null. */ + private static String myGoingTerritory(String myUuid) { + for (Map.Entry> entry : going.entrySet()) { + for (WarGoer goer : entry.getValue()) { + if (goer.uuid().equals(myUuid)) { + return entry.getKey(); + } + } + } + return null; } /** Clear all local war-board state (on world change / disconnect). */ @@ -208,6 +259,8 @@ public static void reset() { clickBoxes.clear(); headBoxes.clear(); soonestTerritory = null; + wasGoing = false; + lastReportedInside = false; } public static void render(BridgeConfig config, GuiGraphics graphics) { @@ -328,9 +381,12 @@ private static void drawHeads(GuiGraphics graphics, Font font, int relX, int rel Minecraft mc = Minecraft.getInstance(); int shown = Math.min(goers.size(), MAX_HEADS); for (int i = 0; i < shown; i++) { + WarGoer goer = goers.get(i); int headRelX = relX + i * (HEAD_SIZE + HEAD_GAP); - PlayerFaceRenderer.draw(graphics, skinFor(mc, goers.get(i)), headRelX, relY, HEAD_SIZE); - headBoxes.add(headBox(headRelX, relY, HEAD_SIZE, HEAD_SIZE, originX, originY, scale, List.of(nameFor(mc, goers.get(i))))); + PlayerFaceRenderer.draw(graphics, skinFor(mc, goer), headRelX, relY, HEAD_SIZE); + // Border: green once they're inside the territory, red while heading there. + drawBorder(graphics, headRelX, relY, HEAD_SIZE, goer.inside() ? BORDER_GREEN : BORDER_RED); + headBoxes.add(headBox(headRelX, relY, HEAD_SIZE, HEAD_SIZE, originX, originY, scale, List.of(nameFor(mc, goer)))); } int overflow = goers.size() - shown; if (overflow > 0) { @@ -345,6 +401,14 @@ private static void drawHeads(GuiGraphics graphics, Font font, int relX, int rel } } + /** Draw a 1px frame on the edge of a head to mark a goer's inside/en-route status. */ + private static void drawBorder(GuiGraphics graphics, int x, int y, int size, int color) { + graphics.fill(x, y, x + size, y + 1, color); + graphics.fill(x, y + size - 1, x + size, y + size, color); + graphics.fill(x, y, x + 1, y + size, color); + graphics.fill(x + size - 1, y, x + size, y + size, color); + } + /** A screen-space hover box from a panel-relative (x, y) run of {@code width}x{@code height}px. */ private static HeadBox headBox(int relX, int relY, int width, int height, int originX, int originY, float scale, List names) { return new HeadBox(Math.round(originX + relX * scale), Math.round(originY + relY * scale), Math.round(originX + (relX + width) * scale), Math.round(originY + (relY + height) * scale), names); @@ -366,16 +430,30 @@ private static String nameFor(Minecraft mc, WarGoer goer) { return goer.name(); } + private static final Logger LOGGER = LoggerFactory.getLogger("edenmod"); + /** Skins resolved by uuid from Mojang, for goers not in our player list (cross-world). */ + private static final Map resolvedSkins = new ConcurrentHashMap<>(); + /** Uuids with a resolve in flight, so each is fetched at most once. */ + private static final Set skinFetching = ConcurrentHashMap.newKeySet(); + private static final ExecutorService skinWorker = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "eden-war-skins"); + thread.setDaemon(true); + return thread; + }); + /** - * The skin to draw for a goer: an online guildmate's already-loaded skin from the - * tab list (no async, no Steve), falling back to the uuid's default skin. + * The skin to draw for a goer. A guildmate on our own world is in the player list with + * their skin already loaded (free). One on a different Wynncraft world instance + * (routine during a war rush) isn't in our list, so their profile carries no textures and + * the game would only give a default skin — we resolve those by uuid from Mojang in the + * background (cached), showing the default until it lands. */ private static PlayerSkin skinFor(Minecraft mc, WarGoer goer) { UUID uuid; try { uuid = UUID.fromString(goer.uuid()); } catch (IllegalArgumentException e) { - return DefaultPlayerSkin.get(UUID.nameUUIDFromBytes(goer.name().getBytes())); + return DefaultPlayerSkin.get(UUID.nameUUIDFromBytes(goer.name().getBytes(StandardCharsets.UTF_8))); } if (mc.getConnection() != null) { PlayerInfo info = mc.getConnection().getPlayerInfo(uuid); @@ -383,9 +461,40 @@ private static PlayerSkin skinFor(Minecraft mc, WarGoer goer) { return info.getSkin(); } } + PlayerSkin resolved = resolvedSkins.get(uuid); + if (resolved != null) { + return resolved; + } + fetchSkin(mc, uuid, goer.name()); return DefaultPlayerSkin.get(uuid); } + /** + * Resolve a goer's skin by uuid off-thread (Mojang session lookup → texture load), + * caching the result. Runs once per uuid; a transient failure clears the guard so a + * later frame retries, a successful profile fetch does not (the skin loads on its own). + */ + private static void fetchSkin(Minecraft mc, UUID uuid, String name) { + if (!skinFetching.add(uuid)) { + return; + } + LOGGER.debug("Resolving war-head skin for {} ({}) — not in the player list", name, uuid); + skinWorker.submit(() -> { + try { + var result = mc.services().sessionService().fetchProfile(uuid, false); + if (result == null) { + skinFetching.remove(uuid); + return; + } + GameProfile profile = result.profile(); + mc.execute(() -> mc.getSkinManager().get(profile).thenAccept(skin -> skin.ifPresent(value -> resolvedSkins.put(uuid, value)))); + } catch (RuntimeException e) { + skinFetching.remove(uuid); + LOGGER.debug("War-head skin fetch failed for {}", uuid, e); + } + }); + } + /** * Handle a click on a timer row while the chat screen is open; true if a row was hit. * Left-click points Wynncraft's compass at the territory; right-click toggles this From 17d576d465eddee58d104ee6a0978461476803d5 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:55:27 +0300 Subject: [PATCH 4/5] War heads: fix skin-fetch retry storm + settle inside-status client-side - Cross-world skin fetch is now rate-limited per uuid (30s) instead of clearing an in-flight guard on failure. skinFor runs every frame, so the old code re-submitted a failing Mojang fetch every frame while the head was visible; the backoff caps it and makes the failure/empty paths symmetric. - Inside-status crossings now must hold 500ms before being reported, and the settled value is what's sent. Border-edge jitter no longer spams updates, and because the mod sends the settled state (not a dropped one) the backend can't be left with a stale inside flag. The server-side inside debounce is removed accordingly. --- src/tel/eden/mod/war/AttackTimerMenu.java | 66 +++++++++++++++++------ 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/tel/eden/mod/war/AttackTimerMenu.java b/src/tel/eden/mod/war/AttackTimerMenu.java index da8c814..c70708b 100644 --- a/src/tel/eden/mod/war/AttackTimerMenu.java +++ b/src/tel/eden/mod/war/AttackTimerMenu.java @@ -6,7 +6,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -87,6 +86,11 @@ private AttackTimerMenu() { // heading anywhere, so it only sends on a change (enter/leave the target territory). private static boolean wasGoing; private static boolean lastReportedInside; + // A crossing must hold this long before it's reported, so wiggling on a territory edge + // never spams updates. The settled value is what gets sent, so state can't desync. + private static final long INSIDE_SETTLE_MS = 500L; + private static boolean pendingInside; + private static long pendingSince; /** Screen rectangles [x1,y1,x2,y2] of the last-rendered rows, for click hit-testing. */ private static final Map clickBoxes = new HashMap<>(); /** Screen rectangles of the last-rendered heads and the names to show on hover. */ @@ -228,12 +232,34 @@ private static void reportInside(BridgeWebSocketClient socket) { } BlockPos pos = mc.player.blockPosition(); boolean inside = myTerritory.equals(TerritoryData.territoryAt(pos.getX(), pos.getZ())); - if (!wasGoing || inside != lastReportedInside) { + if (!wasGoing) { + // First report of this trip: send the initial state at once. wasGoing = true; lastReportedInside = inside; - if (socket != null) { - socket.sendWarGoingInside(inside); - } + pendingInside = inside; + sendInside(socket, inside); + return; + } + if (inside == lastReportedInside) { + pendingInside = inside; // steady at the value the backend already has + return; + } + // Differs from what the backend has: only report once it has held for the settle + // window, and always the settled value — so border-edge jitter can't spam, and the + // backend never ends up with a stale value we then never correct. + long now = System.currentTimeMillis(); + if (inside != pendingInside) { + pendingInside = inside; + pendingSince = now; + } else if (now - pendingSince >= INSIDE_SETTLE_MS) { + lastReportedInside = inside; + sendInside(socket, inside); + } + } + + private static void sendInside(BridgeWebSocketClient socket, boolean inside) { + if (socket != null) { + socket.sendWarGoingInside(inside); } } @@ -261,6 +287,8 @@ public static void reset() { soonestTerritory = null; wasGoing = false; lastReportedInside = false; + pendingInside = false; + pendingSince = 0; } public static void render(BridgeConfig config, GuiGraphics graphics) { @@ -433,8 +461,13 @@ private static String nameFor(Minecraft mc, WarGoer goer) { private static final Logger LOGGER = LoggerFactory.getLogger("edenmod"); /** Skins resolved by uuid from Mojang, for goers not in our player list (cross-world). */ private static final Map resolvedSkins = new ConcurrentHashMap<>(); - /** Uuids with a resolve in flight, so each is fetched at most once. */ - private static final Set skinFetching = ConcurrentHashMap.newKeySet(); + /** + * Earliest time (ms) each uuid may be (re)fetched. Set on every attempt so a failed or + * empty resolve backs off instead of re-submitting every frame; a success caches into + * {@link #resolvedSkins} and is served before this map is ever consulted again. + */ + private static final Map skinNextAttempt = new ConcurrentHashMap<>(); + private static final long SKIN_RETRY_MS = 30_000L; private static final ExecutorService skinWorker = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "eden-war-skins"); thread.setDaemon(true); @@ -471,25 +504,26 @@ private static PlayerSkin skinFor(Minecraft mc, WarGoer goer) { /** * Resolve a goer's skin by uuid off-thread (Mojang session lookup → texture load), - * caching the result. Runs once per uuid; a transient failure clears the guard so a - * later frame retries, a successful profile fetch does not (the skin loads on its own). + * caching the result. Rate-limited per uuid ({@link #SKIN_RETRY_MS}) so a failed or + * empty resolve backs off instead of re-submitting every frame the head is drawn; a + * success caches the skin, which is then served before this method is called again. */ private static void fetchSkin(Minecraft mc, UUID uuid, String name) { - if (!skinFetching.add(uuid)) { + long now = System.currentTimeMillis(); + Long next = skinNextAttempt.get(uuid); + if (next != null && now < next) { return; } + skinNextAttempt.put(uuid, now + SKIN_RETRY_MS); LOGGER.debug("Resolving war-head skin for {} ({}) — not in the player list", name, uuid); skinWorker.submit(() -> { try { var result = mc.services().sessionService().fetchProfile(uuid, false); - if (result == null) { - skinFetching.remove(uuid); - return; + if (result != null) { + GameProfile profile = result.profile(); + mc.execute(() -> mc.getSkinManager().get(profile).thenAccept(skin -> skin.ifPresent(value -> resolvedSkins.put(uuid, value)))); } - GameProfile profile = result.profile(); - mc.execute(() -> mc.getSkinManager().get(profile).thenAccept(skin -> skin.ifPresent(value -> resolvedSkins.put(uuid, value)))); } catch (RuntimeException e) { - skinFetching.remove(uuid); LOGGER.debug("War-head skin fetch failed for {}", uuid, e); } }); From c780c2d4157eec45be2d5435249354125e01e2cb Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:05:23 +0300 Subject: [PATCH 5/5] Bump version to 1.3.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index bd4ec0f..5d466fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ loader_version=0.19.3 fabric_version=0.141.4+1.21.11 loom_version=1.17.8 -mod_version=1.3.0 +mod_version=1.3.1 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0