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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions resources/edenmod.mixins.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"ChatScreenMixin",
"ClientPacketListenerCommandAliasMixin",
"ClientPacketListenerMixin",
"ConnectionScoreboardMixin",
"GuiGraphicsMixin",
"ItemRenderMixin",
"TitleScreenMixin"
Expand Down
40 changes: 35 additions & 5 deletions src/tel/eden/mod/EdenModClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -623,6 +632,14 @@ public void onWarCounts(int days, java.util.List<WarCountEntry> entries, String
displayColored(color, () -> DiscordChatFormatter.warCounts(days, entries, requester));
}

@Override
public void onWarBoard(java.util.List<WarBoardEntry> 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;
Expand Down Expand Up @@ -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;
Expand All @@ -1667,10 +1688,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) {
Expand Down Expand Up @@ -1794,8 +1820,12 @@ public void handleSystemChat(Component message) {
Optional<CapturedMessage> captured = GuildChatParser.parse(message);
if (captured.isPresent() && config.warAttackTimers) {
// Fold any "<territory> defense is <rating>" 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();
Expand Down
7 changes: 7 additions & 0 deletions src/tel/eden/mod/config/BridgeConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 32 additions & 0 deletions src/tel/eden/mod/gui/BridgeConfigScreen.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
}
37 changes: 17 additions & 20 deletions src/tel/eden/mod/item/CustomItemTextures.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ private record Rule(List<Pattern> names, List<Pattern> 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<Long, Identifier> DECISION_CACHE = new HashMap<>();
private static final Map<CacheKey, Identifier> 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<String, List<Rule>> compileRules() {
Map<String, List<Rule>> byType = new HashMap<>();
Expand Down Expand Up @@ -81,21 +87,21 @@ 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<String> 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) {
stack.set(DataComponents.ITEM_MODEL, cached);
}
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<String> lore = loreStrings(stack);
Identifier match = name.equals("Air") ? null : resolve(stack, name, lore);
if (DECISION_CACHE.size() >= CACHE_LIMIT) {
DECISION_CACHE.clear();
}
Expand Down Expand Up @@ -175,15 +181,6 @@ private static String itemType(ItemStack stack, List<String> lore) {
return null;
}

/** Cheap fingerprint of an item's identity for the decision cache. */
private static long fingerprint(String name, List<String> lore) {
long hash = name.hashCode();
for (String line : lore) {
hash = hash * 31 + line.hashCode();
}
return hash;
}

private static boolean nameMatches(List<Pattern> patterns, String name) {
for (Pattern pattern : patterns) {
if (!pattern.matcher(name).matches()) {
Expand Down
10 changes: 7 additions & 3 deletions src/tel/eden/mod/mixin/ChatScreenMixin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> 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);
}
}
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions src/tel/eden/mod/mixin/ConnectionScoreboardMixin.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Injected at the <em>head of {@code Connection.channelRead0}</em> — the netty entry
* point that later calls {@code packet.handle(listener)}. This runs strictly before any
* packet-<em>listener</em> 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.
*
* <p>{@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));
}
}
}
Loading
Loading